diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index d62866f3e95..a96647dfe09 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -6,6 +6,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts', 'src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts', 'src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts', + 'src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts', 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', 'src/main/pi/rpc-configured-default.test.ts', diff --git a/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts b/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts index 043dd5829b1..ae7fdf06a5a 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { wslMoveCutoverFixture as cutover } from '../../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' -import { writeWslMoveCutover } from '../../../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { seedWslMoveCutover } from '../../../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' import { WslMoveProjectFencedError, loadWslMoveFence, @@ -24,7 +24,7 @@ const accept = (worktreeId: string, sessionId?: string) => beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-spawn-')) loadWslMoveFence(userDataPath) - writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) listLiveDaemonPtyIds.mockReset() }) afterEach(() => { diff --git a/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts index 1c6f5cea7d8..c4e566178b3 100644 --- a/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts +++ b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts @@ -7,7 +7,7 @@ import { closeTestStores, createStore, testState } from '../../persistence-test- import type { Store } from '../../persistence' import type { Repo } from '../../../shared/repo-types' import { wslMoveCutoverFixture as cutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' -import { writeWslMoveCutover } from '../../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { seedWslMoveCutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' import { WslMoveProjectFencedError, loadWslMoveFence, @@ -83,7 +83,7 @@ describe('project host setups under a WSL move', () => { it('refuses IPC edits to a local setup of a moving repo and leaves others alone', async () => { store.addRepo(repo('repo-1')) store.addRepo(repo('repo-2')) - writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) registerProjectHostSetupHandlers(new BrowserWindow(), store) const moving = setupFor('repo-1') @@ -107,7 +107,7 @@ describe('project host setups under a WSL move', () => { it("keeps a setup's own repo frozen once the WSL host owns it", async () => { store.addRepo(repo('repo-1')) - writeWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed')) + seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed')) registerProjectHostSetupHandlers(new BrowserWindow(), store) const owned = setupFor('repo-1') @@ -121,7 +121,7 @@ describe('project host setups under a WSL move', () => { it('refuses a runtime setup delete for a moving repo', () => { store.addRepo(repo('repo-1')) - writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) const controller = new RuntimeProjectHostSetupController({ getStore: () => store, listRepos: () => store.getRepos(), @@ -145,7 +145,7 @@ describe('project host setups under a WSL move', () => { describe('projects:update once the WSL host owns a repo', () => { it('lets project-wide settings change again', async () => { store.addRepo(repo('repo-1')) - writeWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed')) + seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed')) registerRepoCatalogHandlers(new BrowserWindow(), store) const project = store.getProjects().find((entry) => entry.sourceRepoIds.includes('repo-1')) const updates = { localWindowsRuntimePreference: { kind: 'windows-host' } } @@ -159,7 +159,7 @@ describe('projects:update once the WSL host owns a repo', () => { describe('issue command writes under a WSL move', () => { it('refuses a runtime write to a moving repo before touching the file', async () => { store.addRepo(repo('repo-1')) - writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) const write = vi.spyOn(issueCommandFile, 'writeIssueCommand') const commands = new RuntimeRepositoryIssueCommand({ resolveRepo: async (id) => repo(id), @@ -174,7 +174,7 @@ describe('issue command writes under a WSL move', () => { describe('repo settings on another host under a WSL move', () => { it('leaves a same-id repo row on another host writable', async () => { - writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) const onHost = repo('repo-1', { executionHostId: 'runtime:wsl:ubuntu' }) vi.spyOn(store, 'updateRepo').mockReturnValue(onHost) const controller = new RuntimeRepositorySettingsController({ diff --git a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts index a874ce1b694..1b8ec667fa5 100644 --- a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts +++ b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts @@ -7,7 +7,7 @@ import { closeTestStores, createStore, testState } from '../../persistence-test- import type { Store } from '../../persistence' import type { Repo } from '../../../shared/repo-types' import { wslMoveCutoverFixture as cutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' -import { writeWslMoveCutover } from '../../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { seedWslMoveCutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' import { WslMoveProjectFencedError, loadWslMoveFence, @@ -74,7 +74,7 @@ describe('folderWorkspaces:update under a WSL move', () => { createdFrom: 'folder-scan' }) const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'notes' }) - writeWslMoveCutover( + seedWslMoveCutover( testState.dir, cutover('move-1', 'source-fenced', {}, { folderWorkspaceIds: [workspace.id] }) ) @@ -111,7 +111,7 @@ describe('projects:update under a WSL move', () => { it('refuses a runtime preference change when a source repo is moving', async () => { store.addRepo(repo('repo-1')) store.addRepo(repo('repo-2')) - writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) registerRepoCatalogHandlers(new BrowserWindow(), store) const projectFor = (repoId: string) => store.getProjects().find((project) => project.sourceRepoIds.includes(repoId)) @@ -129,7 +129,7 @@ describe('projects:update under a WSL move', () => { describe('RuntimeRepositorySettingsController under a WSL move', () => { it('refuses a settings edit to a moving repo and writes nothing', async () => { store.addRepo(repo('repo-1')) - writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) const notifyReposChanged = vi.fn() const controller = new RuntimeRepositorySettingsController({ getStore: () => store, diff --git a/src/main/ipc/worktrees-wsl-move-fence.test.ts b/src/main/ipc/worktrees-wsl-move-fence.test.ts index 81e98bd3b9e..1c77daca546 100644 --- a/src/main/ipc/worktrees-wsl-move-fence.test.ts +++ b/src/main/ipc/worktrees-wsl-move-fence.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' -import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' import { WslMoveProjectFencedError, loadWslMoveFence, @@ -53,7 +53,7 @@ beforeEach(() => { setupWorktreeHandlers() userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-worktree-ipc-')) loadWslMoveFence(userDataPath) - writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) }) afterEach(() => { resetWslMoveFenceForTests() diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding.ts new file mode 100644 index 00000000000..24dcfd08e64 --- /dev/null +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding.ts @@ -0,0 +1,15 @@ +/** Writes a move's record through every phase up to its own, as the journal requires. */ +import { + WSL_MOVE_CUTOVER_PHASES, + type WslMoveCutover +} from '../../../../shared/wsl-move/wsl-move-cutover' +import { writeWslMoveCutover } from '../../moving-wsl-projects/wsl-move-cutover-journal' + +export function seedWslMoveCutover(userDataPath: string, cutover: WslMoveCutover): void { + for (const phase of WSL_MOVE_CUTOVER_PHASES) { + writeWslMoveCutover(userDataPath, { ...cutover, phase }) + if (phase === cutover.phase) { + return + } + } +} diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index bb22de808c4..47a18973e47 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import type { GhAccountBinding } from '../../../shared/github/account-binding' @@ -27,6 +28,17 @@ import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' import { scheduleSave } from './write-scheduling' import { pruneDeregisteredRepoUiResidue } from './repo-lifecycle-ui-residue' +import { isWslMoveProjectSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard' + +// A paused WSL move keeps its local rows for a downgrade. +function removeLocalRepoSession( + session: WorkspaceSessionState, + repoId: string +): WorkspaceSessionState { + return isWslMoveProjectSessionFrozen({ repoId }) + ? session + : removeRepoFromWorkspaceSession(session, repoId) +} type RepoLifecycleOperationsRuntime = Pick< StoreRuntimeState, | 'gitUsernameCache' @@ -83,11 +95,10 @@ export class RepoLifecycleOperations { syncProjectHostSetupCompatibilityState(this) pruneWorktreeStateForRepo(this, id, idStillPresent ? hostId : null) if (!idStillPresent) { - this[repoLifecycleOperationsContext].runtime.state.workspaceSession = - removeRepoFromWorkspaceSession( - this[repoLifecycleOperationsContext].runtime.state.workspaceSession, - id - ) + this[repoLifecycleOperationsContext].runtime.state.workspaceSession = removeLocalRepoSession( + this[repoLifecycleOperationsContext].runtime.state.workspaceSession, + id + ) this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, @@ -121,7 +132,7 @@ export class RepoLifecycleOperations { for (const repoId of orphanRepoIds) { retireLocalWorktreeScanGeneration(repoId) pruneWorktreeStateForRepo(this, repoId, null) - state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId) + state.workspaceSession = removeLocalRepoSession(state.workspaceSession, repoId) state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( state.workspaceSessionsByHostId, repoId diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index c5a13c505ed..aace1ce4b68 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -17,6 +17,7 @@ import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-author import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence' import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' +import { isWslMoveWorkspaceSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard' import { removeWorkspaceSessionOwner, workspaceSessionPartitionIdsForHost @@ -144,6 +145,10 @@ export function removeWorkspaceSessionOwnerInPartition( if (!hasPersistedWorkspaceSession(owner, resolved)) { return } + // A paused WSL move keeps its local rows for a downgrade. + if (resolved === LOCAL_EXECUTION_HOST_ID && isWslMoveWorkspaceSessionFrozen(worktreeId)) { + return + } const current = owner.getWorkspaceSession(resolved) const session = removeWorkspaceSessionOwner(current, worktreeId, { advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index d92a2cf8e14..88c209485b3 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -19,6 +19,7 @@ import { withRequiredWorkspaceSessionMaps } from './session-host-partitions' import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence' +import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze' import { scheduleSave } from './write-scheduling' import { orderTerminalRowsByTabGroups } from '../../../shared/workspace-layout/terminal-row-order' @@ -60,7 +61,12 @@ export class SessionSnapshotOperations { if (runtime.durableMutationPhase === 'rollback') { this.assertSnapshotAdmission(true) // The fieldwise rollback already preserves newer edits; renderer rebasing would undo it. - this.publishSession(session, resolved) + this.publishSession( + resolved === LOCAL_EXECUTION_HOST_ID + ? keepWslMoveFrozenSessionsForStore(session, runtime.state.workspaceSession) + : session, + resolved + ) return } if (resolved === LOCAL_EXECUTION_HOST_ID) { @@ -102,6 +108,10 @@ export class SessionSnapshotOperations { if (Object.hasOwn(patch, 'unifiedTabs')) { next = dropClosedTerminalTabs(next) } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + const { state } = this[sessionSnapshotOperationsContext].runtime + next = keepWslMoveFrozenSessionsForStore(next, state.workspaceSession) + } this.publishSession(next, resolved) } diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 4fbe44b75e5..d1446c701c4 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -12,6 +12,7 @@ import { } from '../../terminal-scrollback-snapshot-async-migration' import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence' +import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze' import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' import { normalizeWorkspaceSessionPaneIdentities, @@ -37,6 +38,7 @@ export function setLocalWorkspaceSession( ): void { const context = getSessionSnapshotOperationsContext(owner) const prior = context.runtime.state.workspaceSession + session = keepWslMoveFrozenSessionsForStore(session, prior) // Why here and not at the callers: the before-unload stage path writes the renderer's payload // straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows. session = dropClosedTerminalTabs(preserveRuntimeAuthoredWorkspaceSessionFields(session, prior)) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts index 028da35c276..3944b9907a7 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.test.ts @@ -2,8 +2,12 @@ import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover' +import { + WSL_MOVE_CUTOVER_PHASES, + wslMoveJournalKey +} from '../../../shared/wsl-move/wsl-move-cutover' import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' import { orcadMigrationCutoverJournalDirectory } from '../../ssh/orcad-migration-cutover-journal' import { listWslMoveCutoverChain, @@ -47,7 +51,7 @@ describe('WSL move journal', () => { }) it('refuses a second move into a distro while the first is in flight', () => { - writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'quiesced')) expect(() => writeWslMoveCutover( userDataPath, @@ -57,7 +61,7 @@ describe('WSL move journal', () => { }) it('chains a delta move after a finished one, and only onto the head', () => { - writeWslMoveCutover(userDataPath, cutover('move-1', 'retained')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'retained')) expect(() => writeWslMoveCutover(userDataPath, cutover('move-2'))).toThrow( 'wsl_move_must_extend_its_chain' ) @@ -77,7 +81,7 @@ describe('WSL move journal', () => { }) it('never moves a record backwards, rebinds it, or advances a superseded one', () => { - writeWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))).toThrow( 'wsl_move_phase_regressed' ) @@ -87,6 +91,7 @@ describe('WSL move journal', () => { cutover('move-1', 'retained', {}, { repoIds: ['repo-1', 'repo-2'] }) ) ).toThrow('wsl_move_binding_changed') + writeWslMoveCutover(userDataPath, cutover('move-1', 'client-rewritten')) writeWslMoveCutover(userDataPath, cutover('move-1', 'retained')) writeWslMoveCutover( userDataPath, @@ -97,7 +102,7 @@ describe('WSL move journal', () => { }) it('hands out records a caller cannot edit into the next comparison', () => { - writeWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) const [listed] = listWslMoveCutovers(userDataPath) if (listed) { listed.phase = 'prepared' @@ -122,12 +127,40 @@ describe('WSL move journal', () => { ).toThrow() }) - it('keeps the fence: an in-flight record cannot be removed', () => { - writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + it('refuses a later phase whose record is missing or was lifted, or that skips a phase', () => { + expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))).toThrow( + 'wsl_move_record_missing' + ) + seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + removeWslMoveCutover(userDataPath, 'move-1') + expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))).toThrow( + 'wsl_move_record_missing' + ) + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared')) + expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))).toThrow( + 'wsl_move_phase_skipped' + ) + }) + + it.each(WSL_MOVE_CUTOVER_PHASES.slice(2, -1))('refuses to remove a %s record', (phase) => { + seedWslMoveCutover(userDataPath, cutover('move-1', phase)) + expect(() => removeWslMoveCutover(userDataPath, 'move-1')).toThrow('wsl_move_in_flight') + expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe(phase) + }) + + it('keeps the fence: a record past quiesce cannot be removed', () => { + seedWslMoveCutover(userDataPath, cutover('move-1', 'quiesced')) expect(() => removeWslMoveCutover(userDataPath, 'move-1')).toThrow('wsl_move_in_flight') expect(listWslMoveCutovers(userDataPath)).toHaveLength(1) }) + it('lifts a fence whose quiesce refused: nothing reached the destination yet', () => { + seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + removeWslMoveCutover(userDataPath, 'move-1') + expect(listWslMoveCutovers(userDataPath)).toEqual([]) + }) + it('fails closed on a manifest edited without re-signing it', () => { const record = cutover() const edited = { diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts index 6032fb6cfdc..2cb6b835c86 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal.ts @@ -81,7 +81,8 @@ export function listWslMoveCutoverChain( } /** - * Durable before it returns. A new move into a distro must extend the head of its chain, and only + * Durable before it returns. A record starts at `prepared` and then advances one phase at a time + * from the one on disk. A new move into a distro must extend the head of its chain, and only * once that head finished: one in-flight move per distro (design, section 4.3). */ export function writeWslMoveCutover(userDataPath: string, cutover: WslMoveCutover): void { @@ -97,6 +98,10 @@ export function writeWslMoveCutover(userDataPath: string, cutover: WslMoveCutove const chain = listWslMoveCutoverChain(userDataPath, parsed.journalKey) const existing = chain.find((entry) => entry.migrationId === parsed.migrationId) const head = chain.at(-1) + // Compare-and-swap: a writer whose record was lifted underneath it must not recreate the fence. + if (!existing && parsed.phase !== 'prepared') { + throw new Error('wsl_move_record_missing') + } if (existing) { assertWslMoveUpdate(existing, parsed, head) } else if (!head) { @@ -130,20 +135,34 @@ function assertWslMoveUpdate( if (order(next.phase) < order(existing.phase)) { throw new Error('wsl_move_phase_regressed') } + if (order(next.phase) > order(existing.phase) + 1) { + throw new Error('wsl_move_phase_skipped') + } if (head && head.migrationId !== existing.migrationId && next.phase !== existing.phase) { throw new Error('wsl_move_superseded') } } +// Before quiesce nothing has reached the destination, so lifting the fence loses nothing. +const REMOVABLE_IN_FLIGHT_PHASES: ReadonlySet = new Set([ + 'prepared', + 'source-fenced' +]) + /** - * Only a record that fences nothing goes: one never fenced (`prepared`) or one finished. A move in - * between keeps its fence until it resumes, or until a cancel proves the destination holds nothing. + * Only a record whose move has touched nothing beyond the source goes, or one finished. A move + * further along keeps its fence until it resumes, or until a cancel proves the destination holds + * nothing. */ export function removeWslMoveCutover(userDataPath: string, migrationId: string): void { const record = listWslMoveCutovers(userDataPath).find( (entry) => entry.migrationId === migrationId ) - if (record && record.phase !== 'prepared' && !isWslMoveCutoverFinished(record)) { + if ( + record && + !REMOVABLE_IN_FLIGHT_PHASES.has(record.phase) && + !isWslMoveCutoverFinished(record) + ) { throw new Error('wsl_move_in_flight') } journals.remove(userDataPath, migrationId) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-fence.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-fence.test.ts index 12a8fe06eba..0215ec56fa3 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-fence.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-fence.test.ts @@ -4,16 +4,30 @@ import { wslMoveFenceFromCutovers } from './wsl-move-fence' const ids = (state: ReturnType) => ({ fenced: [...state.fenced.repoIds, ...state.fenced.folderWorkspaceIds], - hidden: [...state.hidden.repoIds, ...state.hidden.folderWorkspaceIds] + hidden: [...state.hidden.repoIds, ...state.hidden.folderWorkspaceIds], + sessionFrozen: [...state.sessionFrozen.repoIds, ...state.sessionFrozen.folderWorkspaceIds] }) describe('WSL move fence', () => { it.each([ - ['prepared', { fenced: [], hidden: [] }], - ['source-fenced', { fenced: ['repo-1', 'notes'], hidden: [] }], - ['blobs-transferred', { fenced: ['repo-1', 'notes'], hidden: [] }], - ['destination-committed', { fenced: [], hidden: ['repo-1', 'notes'] }], - ['retained', { fenced: [], hidden: ['repo-1', 'notes'] }] + ['prepared', { fenced: [], hidden: [], sessionFrozen: [] }], + // The quiesce's own Sleep still writes the session here. + ['source-fenced', { fenced: ['repo-1', 'notes'], hidden: [], sessionFrozen: [] }], + ['quiesced', { fenced: ['repo-1', 'notes'], hidden: [], sessionFrozen: ['repo-1', 'notes'] }], + [ + 'blobs-transferred', + { fenced: ['repo-1', 'notes'], hidden: [], sessionFrozen: ['repo-1', 'notes'] } + ], + // Through commit: the retained local session is what a downgrade restores. + [ + 'destination-committed', + { fenced: [], hidden: ['repo-1', 'notes'], sessionFrozen: ['repo-1', 'notes'] } + ], + [ + 'client-rewritten', + { fenced: [], hidden: ['repo-1', 'notes'], sessionFrozen: ['repo-1', 'notes'] } + ], + ['retained', { fenced: [], hidden: ['repo-1', 'notes'], sessionFrozen: ['repo-1', 'notes'] }] ] as const)('%s', (phase, expected) => { const record = cutover( 'move-1', @@ -29,6 +43,10 @@ describe('WSL move fence', () => { cutover('move-1', 'retained', {}, { repoIds: ['repo-1'] }), cutover('move-2', 'quiesced', { supersedesMigrationId: 'move-1' }, { repoIds: ['repo-2'] }) ]) - expect(ids(state)).toEqual({ fenced: ['repo-2'], hidden: ['repo-1'] }) + expect(ids(state)).toEqual({ + fenced: ['repo-2'], + hidden: ['repo-1'], + sessionFrozen: ['repo-1', 'repo-2'] + }) }) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts b/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts index 0887cd9130d..e29a629a598 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-fence.ts @@ -3,9 +3,10 @@ * lives outside the profile store, so an older build rewriting the profile cannot drop it, and the * scope it names is the manifest's own (design, section 4.3). * - * - Fenced (read-only): from `source-fenced` until the host owns the projects. Edits, new - * terminals and session writes for these rows are refused. + * - Fenced (read-only): from `source-fenced` until the host owns the projects. Edits and new + * terminals are refused. * - Hidden: once the destination committed. The source rows stay (retained) but are not listed. + * - Session frozen: from `quiesced` on, hidden or not, the session stays as the quiesce left it. */ import type { WslMoveCutover, WslMoveCutoverPhase } from '../../../shared/wsl-move/wsl-move-cutover' import { WSL_MOVE_CUTOVER_PHASES } from '../../../shared/wsl-move/wsl-move-cutover' @@ -21,11 +22,15 @@ export type WslMoveFenceState = { fenced: WslMoveScopeIds /** Rows the WSL host now owns: kept for a downgrade, never listed. */ hidden: WslMoveScopeIds + /** Rows already paused, through commit: their session stays as the quiesce left it. */ + sessionFrozen: WslMoveScopeIds } const phaseIndex = (phase: WslMoveCutoverPhase): number => WSL_MOVE_CUTOVER_PHASES.indexOf(phase) const FENCED_FROM = phaseIndex('source-fenced') const HIDDEN_FROM = phaseIndex('destination-committed') +// Why not from `source-fenced`: the quiesce's own Sleep writes the slept panes' session after that. +const SESSION_FROZEN_FROM = phaseIndex('quiesced') /** Throws when the journal cannot be read: callers keep every WSL project fenced then. */ export function resolveWslMoveFence(userDataPath: string): WslMoveFenceState { @@ -37,19 +42,31 @@ export function wslMoveFenceFromCutovers( ): WslMoveFenceState { const fenced = { repoIds: new Set(), folderWorkspaceIds: new Set() } const hidden = { repoIds: new Set(), folderWorkspaceIds: new Set() } + const sessionFrozen = { repoIds: new Set(), folderWorkspaceIds: new Set() } for (const cutover of cutovers) { const index = phaseIndex(cutover.phase) const target = index >= HIDDEN_FROM ? hidden : index >= FENCED_FROM ? fenced : null if (!target) { continue // `prepared` touches nothing on the source. } - const { scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source - for (const id of scopeRepoIds) { - target.repoIds.add(id) - } - for (const id of scopeFolderWorkspaceIds) { - target.folderWorkspaceIds.add(id) + addScope(target, cutover) + // Through commit too: the retained local rows are what a downgrade restores. + if (index >= SESSION_FROZEN_FROM) { + addScope(sessionFrozen, cutover) } } - return { fenced, hidden } + return { fenced, hidden, sessionFrozen } +} + +function addScope( + target: { repoIds: Set; folderWorkspaceIds: Set }, + cutover: Readonly +): void { + const { scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source + for (const id of scopeRepoIds) { + target.repoIds.add(id) + } + for (const id of scopeFolderWorkspaceIds) { + target.folderWorkspaceIds.add(id) + } } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts new file mode 100644 index 00000000000..e6ad2e5d923 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import type { WslMoveExitProof } from './wsl-move-exit-proof' +import { + quiesceWslMove, + type WslMoveQuiesceDeps, + type WslMoveQuiesceScope +} from './wsl-move-quiesce' +import type { WslMovePaneFacts } from './wsl-move-terminal-gate' + +const WORKTREE = 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1' +const scope: WslMoveQuiesceScope = { + workspaceIds: [WORKTREE], + workspaceKeys: new Set([WORKTREE]), + panes: [{ paneKey: 'tab-1:leaf-1', sessionId: 'session-1' }] +} +const idlePane: WslMovePaneFacts = { + paneKey: 'tab-1:leaf-1', + foreground: 'shell-idle', + scrollbackBytes: 10 +} +const hints = { + hints: [{ paneKey: 'tab-1:leaf-1', cwd: '/home/dev/repo-1' }], + unreadablePaneKeys: [], + staleCwdPaneKeys: [] +} + +function harness(overrides: Partial = {}) { + const journal: WslMoveCutover['phase'][] = [] + const removed: string[] = [] + const steps: string[] = [] + const deps: WslMoveQuiesceDeps = { + gateInputs: async () => ({ + panes: [idlePane], + runningAutomationRunIds: [], + activeDispatchIds: [] + }), + sleepWorkspaces: vi.fn(async () => { + steps.push(`sleep after ${journal.at(-1)}`) + return { failedWorktreeIds: [] } + }), + livePtyIds: () => [], + captureRestoreHints: async () => hints, + proveExited: async (): Promise => ({ verdict: 'exited' }), + writeCutover: (record) => { + journal.push(record.phase) + }, + removeCutover: (migrationId) => { + removed.push(migrationId) + }, + now: () => new Date('2026-10-14T00:00:00.000Z'), + ...overrides + } + return { deps, journal, removed, steps } +} + +describe('quiesceWslMove', () => { + it('fences, sleeps, proves exit and records the proven sessions', async () => { + const { deps, journal, removed, steps } = harness() + const result = await quiesceWslMove(cutover(), scope, deps) + + expect(journal).toEqual(['source-fenced', 'quiesced']) + expect(steps).toEqual(['sleep after source-fenced']) + expect(removed).toEqual([]) + expect(result).toMatchObject({ + outcome: 'quiesced', + cutover: { phase: 'quiesced', provenExitedSessionIds: ['session-1'] }, + restoreHints: hints + }) + }) + + it('refuses with the blockers before fencing anything', async () => { + const { deps, journal } = harness({ + gateInputs: async () => ({ + panes: [{ ...idlePane, foreground: 'process-running' }], + runningAutomationRunIds: [], + activeDispatchIds: [] + }) + }) + const result = await quiesceWslMove(cutover(), scope, deps) + + expect(result).toMatchObject({ + outcome: 'refused', + reason: 'terminals-blocked', + blockers: [{ kind: 'process-running', ids: ['tab-1:leaf-1'] }], + fenceLifted: true + }) + expect(journal).toEqual([]) + expect(deps.sleepWorkspaces).not.toHaveBeenCalled() + }) + + it('gates again under the fence and refuses a pane that turned busy, without sleeping', async () => { + const phases: (WslMoveCutover['phase'] | undefined)[] = [] + let calls = 0 + const { deps, journal, removed } = harness() + deps.gateInputs = async () => { + phases.push(journal.at(-1)) + calls += 1 + const foreground = calls === 1 ? 'shell-idle' : 'process-running' + return { + panes: [{ ...idlePane, foreground }], + runningAutomationRunIds: [], + activeDispatchIds: [] + } + } + const result = await quiesceWslMove(cutover(), scope, deps) + + expect(phases).toEqual([undefined, 'source-fenced']) + expect(result).toMatchObject({ + outcome: 'refused', + reason: 'terminals-blocked', + blockers: [{ kind: 'process-running', ids: ['tab-1:leaf-1'] }], + fenceLifted: true + }) + expect(deps.sleepWorkspaces).not.toHaveBeenCalled() + expect(removed).toEqual(['move-1']) + }) + + it('refuses a second quiesce of the same distro while the first runs', async () => { + let releaseSleep: () => void = () => undefined + const first = harness({ + sleepWorkspaces: () => + new Promise((resolve) => { + releaseSleep = () => resolve({ failedWorktreeIds: [] }) + }) + }) + const running = quiesceWslMove(cutover(), scope, first.deps) + await vi.waitFor(() => expect(first.journal).toEqual(['source-fenced'])) + + const second = harness() + await expect(quiesceWslMove(cutover('move-2'), scope, second.deps)).resolves.toEqual({ + outcome: 'refused', + reason: 'quiesce-in-progress', + fenceLifted: true + }) + expect(second.journal).toEqual([]) + + releaseSleep() + await expect(running).resolves.toMatchObject({ outcome: 'quiesced' }) + await expect(quiesceWslMove(cutover('move-2'), scope, second.deps)).resolves.toMatchObject({ + outcome: 'quiesced' + }) + }) + + it.each([ + ['renderer_unavailable', 'renderer-unavailable'], + ['worktree_sleep_timeout', 'sleep-timed-out'], + ['persist failed', 'sleep-failed'] + ])('lifts the fence when Sleep rejects with %s', async (message, reason) => { + const { deps, journal, removed } = harness({ + sleepWorkspaces: async () => { + throw new Error(message) + } + }) + const result = await quiesceWslMove(cutover(), scope, deps) + + expect(result).toMatchObject({ outcome: 'refused', reason, fenceLifted: true }) + expect(journal).toEqual(['source-fenced']) + expect(removed).toEqual(['move-1']) + }) + + it('lifts the fence and names the workspaces whose Sleep failed', async () => { + const { deps, removed } = harness({ + sleepWorkspaces: async () => ({ failedWorktreeIds: [WORKTREE] }) + }) + const result = await quiesceWslMove(cutover(), scope, deps) + + expect(result).toMatchObject({ + outcome: 'refused', + reason: 'sleep-failed', + failedWorktreeIds: [WORKTREE] + }) + expect(removed).toEqual(['move-1']) + }) + + it('refuses a terminal opened in scope after Sleep, without touching the daemon proof', async () => { + const proveExited = vi.fn(async (): Promise => ({ verdict: 'exited' })) + const { deps, journal, removed } = harness({ livePtyIds: () => ['pty-new'], proveExited }) + const result = await quiesceWslMove(cutover(), scope, deps) + + expect(result).toMatchObject({ reason: 'terminals-live', liveSessionIds: ['pty-new'] }) + expect(proveExited).not.toHaveBeenCalled() + expect(journal).toEqual(['source-fenced']) + expect(removed).toEqual(['move-1']) + }) + + it.each<[WslMoveExitProof, Record]>([ + [ + { verdict: 'live', sessionIds: ['session-1'] }, + { reason: 'terminals-live', liveSessionIds: ['session-1'] } + ], + [ + { verdict: 'unverifiable', reason: 'a daemon generation did not answer' }, + { reason: 'exit-unverifiable', detail: 'a daemon generation did not answer' } + ] + ])('refuses unless the daemon proves exit: %o', async (proof, expected) => { + const { deps, journal, removed } = harness({ proveExited: async () => proof }) + const result = await quiesceWslMove(cutover(), scope, deps) + + expect(result).toMatchObject({ outcome: 'refused', ...expected, fenceLifted: true }) + expect(journal).toEqual(['source-fenced']) + expect(removed).toEqual(['move-1']) + }) + + it('lifts the fence when reading the restore hints throws', async () => { + const { deps, removed } = harness({ + captureRestoreHints: async () => { + throw new Error('disk') + } + }) + const result = await quiesceWslMove(cutover(), scope, deps) + expect(result).toMatchObject({ reason: 'quiesce-failed', detail: 'disk' }) + expect(removed).toEqual(['move-1']) + }) + + it('reports a fence it could not lift', async () => { + const { deps } = harness({ + proveExited: async () => ({ verdict: 'live', sessionIds: ['session-1'] }), + removeCutover: () => { + throw new Error('wsl_move_in_flight') + } + }) + const result = await quiesceWslMove(cutover(), scope, deps) + expect(result).toMatchObject({ outcome: 'refused', fenceLifted: false }) + }) + + it('refuses without lifting anything when the fence cannot be written', async () => { + const { deps, removed } = harness({ + writeCutover: () => { + throw new Error('wsl_move_already_in_flight') + } + }) + const result = await quiesceWslMove(cutover(), scope, deps) + expect(result).toMatchObject({ reason: 'fence-failed', fenceLifted: true }) + expect(removed).toEqual([]) + expect(deps.sleepWorkspaces).not.toHaveBeenCalled() + }) + + it('only starts from a prepared move', async () => { + const { deps } = harness() + await expect(quiesceWslMove(cutover('move-1', 'source-fenced'), scope, deps)).rejects.toThrow( + 'wsl_move_not_prepared' + ) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts new file mode 100644 index 00000000000..5ad37b3e747 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts @@ -0,0 +1,212 @@ +/** + * Pausing a moving project's terminals (mover design, section 3.3): the gate must pass, the fence + * goes up, Sleep pauses every workspace in scope, and the daemon must prove each session exited + * before the journal says `quiesced`. Nothing is ever ended to make progress. + * + * A refusal after the fence lifts it again. Workspaces Sleep already paused stay asleep: that is + * their ordinary sleeping state, and waking them later restores them from it, so nothing is lost. + */ +import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' +import type { WslMoveExitProof } from './wsl-move-exit-proof' +import type { WslMoveRestoreHintCapture } from './wsl-move-restore-hints' +import { + assessWslMoveTerminals, + type WslMoveGateBlocker, + type WslMoveGateInputs +} from './wsl-move-terminal-gate' + +export type WslMoveQuiesceScope = { + /** Every worktree and folder workspace of the moving projects; each one is slept. */ + workspaceIds: readonly string[] + /** Workspace keys as session ids mint them, for the exit proof. */ + workspaceKeys: ReadonlySet + /** The panes the move carries, with the daemon session each one recorded. */ + panes: readonly { paneKey: string; sessionId: string }[] +} + +export type WslMoveQuiesceDeps = { + gateInputs: () => Promise + /** Resolves with the workspaces whose Sleep failed; rejects when the renderer cannot answer. */ + sleepWorkspaces: (workspaceIds: readonly string[]) => Promise<{ failedWorktreeIds: string[] }> + /** PTYs this process still holds for the workspaces, daemon-backed or not. */ + livePtyIds: (workspaceIds: readonly string[]) => readonly string[] + captureRestoreHints: ( + panes: WslMoveQuiesceScope['panes'], + distro: string + ) => Promise + proveExited: (scope: { + workspaceKeys: ReadonlySet + sessionIds: ReadonlySet + }) => Promise + writeCutover: (cutover: WslMoveCutover) => void + removeCutover: (migrationId: string) => void + now: () => Date +} + +export type WslMoveQuiesceRefusalReason = + | 'terminals-blocked' + /** Another quiesce for the same distro is still running in this process. */ + | 'quiesce-in-progress' + | 'fence-failed' + | 'renderer-unavailable' + | 'sleep-timed-out' + | 'sleep-failed' + | 'terminals-live' + | 'exit-unverifiable' + | 'journal-failed' + /** Anything else that threw after the fence went up. */ + | 'quiesce-failed' + +export type WslMoveQuiesceResult = + | { + outcome: 'quiesced' + cutover: WslMoveCutover + // Not in the journal: the schema has no field for them yet, so the caller carries them on. + restoreHints: WslMoveRestoreHintCapture + } + | { + outcome: 'refused' + reason: WslMoveQuiesceRefusalReason + blockers?: WslMoveGateBlocker[] + failedWorktreeIds?: string[] + liveSessionIds?: string[] + detail?: string + /** False only when the journal record could not be removed, so the fence still stands. */ + fenceLifted: boolean + } + +type Refusal = Extract + +// Journal keys with a quiesce running in this process; the journal alone cannot order two of them. +const quiescingJournalKeys = new Set() + +export async function quiesceWslMove( + prepared: WslMoveCutover, + scope: WslMoveQuiesceScope, + deps: WslMoveQuiesceDeps +): Promise { + if (prepared.phase !== 'prepared') { + throw new Error('wsl_move_not_prepared') + } + if (quiescingJournalKeys.has(prepared.journalKey)) { + return { outcome: 'refused', reason: 'quiesce-in-progress', fenceLifted: true } + } + quiescingJournalKeys.add(prepared.journalKey) + try { + return await quiesceUnderLock(prepared, scope, deps) + } finally { + quiescingJournalKeys.delete(prepared.journalKey) + } +} + +async function quiesceUnderLock( + prepared: WslMoveCutover, + scope: WslMoveQuiesceScope, + deps: WslMoveQuiesceDeps +): Promise { + const gate = assessWslMoveTerminals(await deps.gateInputs()) + if (gate.verdict === 'blocked') { + return { + outcome: 'refused', + reason: 'terminals-blocked', + blockers: gate.blockers, + fenceLifted: true + } + } + const fenced: WslMoveCutover = { + ...prepared, + phase: 'source-fenced', + updatedAt: deps.now().toISOString() + } + try { + deps.writeCutover(fenced) + } catch (error) { + return { + outcome: 'refused', + reason: 'fence-failed', + detail: describe(error), + fenceLifted: true + } + } + const refuse = (refusal: Omit): Refusal => { + try { + deps.removeCutover(fenced.migrationId) + return { outcome: 'refused', ...refusal, fenceLifted: true } + } catch (error) { + console.warn('[wsl-move] Could not lift the fence after a refused quiesce:', error) + return { outcome: 'refused', ...refusal, fenceLifted: false } + } + } + + try { + // The fence blocks new terminals, not commands typed into open shells; a sub-second window remains. + const fencedGate = assessWslMoveTerminals(await deps.gateInputs()) + if (fencedGate.verdict === 'blocked') { + return refuse({ reason: 'terminals-blocked', blockers: fencedGate.blockers }) + } + } catch (error) { + return refuse({ reason: 'quiesce-failed', detail: describe(error) }) + } + + try { + const { failedWorktreeIds } = await deps.sleepWorkspaces(scope.workspaceIds) + if (failedWorktreeIds.length > 0) { + return refuse({ reason: 'sleep-failed', failedWorktreeIds }) + } + } catch (error) { + const detail = describe(error) + const reason = + detail === 'renderer_unavailable' + ? 'renderer-unavailable' + : detail === 'worktree_sleep_timeout' + ? 'sleep-timed-out' + : 'sleep-failed' + return refuse({ reason, detail }) + } + + try { + return await proveAndRecord(fenced, scope, deps, refuse) + } catch (error) { + return refuse({ reason: 'quiesce-failed', detail: describe(error) }) + } +} + +async function proveAndRecord( + fenced: WslMoveCutover, + scope: WslMoveQuiesceScope, + deps: WslMoveQuiesceDeps, + refuse: (refusal: Omit) => Refusal +): Promise { + // Not the gate's pane probe: a slept pane has no process to ask, so it would read unverifiable. + const live = deps.livePtyIds(scope.workspaceIds) + if (live.length > 0) { + return refuse({ reason: 'terminals-live', liveSessionIds: [...live] }) + } + const distro = fenced.manifest.source.distro + const restoreHints = await deps.captureRestoreHints(scope.panes, distro) + const sessionIds = new Set(scope.panes.map((pane) => pane.sessionId)) + // Last, so a session that came back while the hints were read still refuses. + const proof = await deps.proveExited({ workspaceKeys: scope.workspaceKeys, sessionIds }) + if (proof.verdict === 'live') { + return refuse({ reason: 'terminals-live', liveSessionIds: proof.sessionIds }) + } + if (proof.verdict === 'unverifiable') { + return refuse({ reason: 'exit-unverifiable', detail: proof.reason }) + } + const quiesced: WslMoveCutover = { + ...fenced, + phase: 'quiesced', + updatedAt: deps.now().toISOString(), + provenExitedSessionIds: [...sessionIds] + } + try { + deps.writeCutover(quiesced) + } catch (error) { + return refuse({ reason: 'journal-failed', detail: describe(error) }) + } + return { outcome: 'quiesced', cutover: quiesced, restoreHints } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.test.ts index 11c56178b00..b731ed75209 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.test.ts @@ -3,11 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' -import { - removeWslMoveCutover, - writeWslMoveCutover, - wslMoveJournalDirectory -} from './wsl-move-cutover-journal' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { removeWslMoveCutover, wslMoveJournalDirectory } from './wsl-move-cutover-journal' import { WslMoveProjectFencedError, assertWslMoveProjectWritable, @@ -67,31 +64,31 @@ describe('wslMoveProjectRefForWorkspace', () => { describe('WSL move scope guard', () => { it('fences nothing before startup loads the journal', () => { resetWslMoveFenceForTests() - writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) expect(isWslMoveFenceLoaded()).toBe(false) expect(writable('repo-1::/x')).not.toThrow() }) it('follows a write under another spelling of the same profile folder', () => { loadWslMoveFence(`${userDataPath}/./`) - writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError) }) it('lifts the fence when the finished move is removed', () => { - writeWslMoveCutover(userDataPath, cutover('move-1', 'retained')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'retained')) expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError) removeWslMoveCutover(userDataPath, 'move-1') expect(writable('repo-1::/x')).not.toThrow() }) it('never matches a project on another host', () => { - writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) expect(writable('ssh:target-1|repo-1::/x')).not.toThrow() }) it('follows journal writes without rereading on every check', () => { - writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced')) expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError) rmSync(wslMoveJournalDirectory(userDataPath), { recursive: true, force: true }) expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError) @@ -103,12 +100,12 @@ describe('WSL move scope guard', () => { }) it('leaves a prepared move untouched', () => { - writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared')) + seedWslMoveCutover(userDataPath, cutover('move-1', 'prepared')) expect(writable('repo-1::/x')).not.toThrow() }) it('refuses work in a fenced project and keeps it listed', () => { - writeWslMoveCutover( + seedWslMoveCutover( userDataPath, cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-1'], folderWorkspaceIds: ['notes'] }) ) @@ -120,7 +117,7 @@ describe('WSL move scope guard', () => { }) it('hides and refuses a project the WSL host owns', () => { - writeWslMoveCutover( + seedWslMoveCutover( userDataPath, cutover('move-1', 'destination-committed', {}, { folderWorkspaceIds: ['notes'] }) ) @@ -131,7 +128,7 @@ describe('WSL move scope guard', () => { }) it("keeps the host's own row, which carries the moved project's id", () => { - writeWslMoveCutover( + seedWslMoveCutover( userDataPath, cutover('move-1', 'destination-committed', {}, { repoIds: ['repo-1'] }) ) @@ -155,7 +152,7 @@ describe('WSL move scope guard', () => { describe('WSL move edit guard', () => { const fenceRepo1AndNotes = () => - writeWslMoveCutover( + seedWslMoveCutover( userDataPath, cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-1'], folderWorkspaceIds: ['notes'] }) ) @@ -200,7 +197,7 @@ describe('WSL move edit guard', () => { }) it('freezes catalog settings only while a source repo is mid-move', () => { - writeWslMoveCutover( + seedWslMoveCutover( userDataPath, cutover('move-1', 'destination-committed', {}, { repoIds: ['repo-1'] }) ) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts index 8271f01f2a0..3d0a39705dc 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts @@ -70,7 +70,10 @@ function readFence(userDataPath: string): FenceView { try { return { readable: true, state: resolveWslMoveFence(userDataPath) } } catch (error) { - console.warn('[wsl-move] Journal unreadable; keeping WSL projects read-only:', error) + console.warn( + '[wsl-move] Journal unreadable; keeping WSL projects read-only, sessions unfrozen:', + error + ) return { readable: false } } } @@ -251,6 +254,30 @@ export function isWslMoveWorkspaceBlocked( return !!workspaceId && isWslMoveProjectBlocked(store, wslMoveProjectRefForWorkspace(workspaceId)) } +/** True once a move has paused the project: its session must stay as the quiesce left it. */ +export function isWslMoveProjectSessionFrozen(ref: WslMoveProjectRef | null): boolean { + const fence = loaded?.view + return !!ref && !!fence?.readable && inScope(fence.state.sessionFrozen, ref) +} + +export function isWslMoveWorkspaceSessionFrozen(workspaceId: string): boolean { + return isWslMoveProjectSessionFrozen(wslMoveProjectRefForWorkspace(workspaceId)) +} + +/** + * Whether any move has a frozen session; lets the hot write path skip the per-workspace scan. + * An unreadable journal freezes nothing: the edit fence already refuses visibly, and no move can + * proceed without a readable journal, so freezing would only discard sessions silently. + */ +export function hasWslMoveFrozenSessions(): boolean { + const fence = loaded?.view + if (!fence?.readable) { + return false + } + const { sessionFrozen } = fence.state + return sessionFrozen.repoIds.size > 0 || sessionFrozen.folderWorkspaceIds.size > 0 +} + export function assertWslMoveWorkspaceWritable( store: ProjectStore | undefined, workspaceId: string | null | undefined diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts new file mode 100644 index 00000000000..19e82ec6200 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts @@ -0,0 +1,167 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { Store } from '../../persistence' +import { + closeTestStores, + createStore, + makeTerminalTab, + testState +} from '../../persistence-test-harness' +import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { pruneWorktreeStateForRepo } from '../tracking-repos/repo-worktree-pruning' +import { wslMoveJournalDirectory } from './wsl-move-cutover-journal' +import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const MOVING = 'repo-1::/home/dev/repo-1' +const STAYING = 'repo-2::/home/dev/repo-2' + +function session(title: string, tabType: 'terminal' | 'editor'): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [MOVING]: [makeTerminalTab({ id: 'moving-tab', ptyId: null, worktreeId: MOVING, title })], + [STAYING]: [makeTerminalTab({ id: 'staying-tab', ptyId: null, worktreeId: STAYING, title })] + }, + terminalLayoutsByTabId: { + 'moving-tab': { root: null, activeLeafId: null, expandedLeafId: null }, + 'staying-tab': { root: null, activeLeafId: null, expandedLeafId: null } + }, + activeTabTypeByWorktree: { [MOVING]: tabType, [STAYING]: tabType } + } +} + +const titles = (store: Store) => { + const { tabsByWorktree } = store.getWorkspaceSession() + return { + moving: tabsByWorktree[MOVING]?.map((tab) => tab.title), + staying: tabsByWorktree[STAYING]?.map((tab) => tab.title) + } +} + +let store: Store +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-freeze-')) + store = createStore() + loadWslMoveFence(testState.dir) + store.setWorkspaceSession(session('slept', 'terminal')) +}) +afterEach(async () => { + vi.restoreAllMocks() + resetWslMoveFenceForTests() + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) +}) + +describe('WSL move session freeze', () => { + it("lets the quiesce's own Sleep write while the move is only fenced", () => { + seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) + store.setWorkspaceSession(session('after-sleep', 'terminal')) + expect(titles(store)).toEqual({ moving: ['after-sleep'], staying: ['after-sleep'] }) + }) + + describe('once quiesced', () => { + beforeEach(() => { + seedWslMoveCutover(testState.dir, cutover('move-1', 'quiesced')) + }) + + it('keeps the moving workspace as the quiesce left it and takes the rest', () => { + store.setWorkspaceSession(session('edited', 'editor')) + expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] }) + expect(store.getWorkspaceSession().activeTabTypeByWorktree).toEqual({ + [MOVING]: 'terminal', + [STAYING]: 'editor' + }) + }) + + it('keeps it when a write omits the workspace entirely', () => { + const without = session('edited', 'editor') + delete without.tabsByWorktree[MOVING] + store.setWorkspaceSession(without) + expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] }) + }) + + it('keeps it through a patch and the before-unload stage', () => { + store.patchWorkspaceSession({ + activeTabTypeByWorktree: { [MOVING]: 'editor', [STAYING]: 'editor' } + }) + expect(store.getWorkspaceSession().activeTabTypeByWorktree).toEqual({ + [MOVING]: 'terminal', + [STAYING]: 'editor' + }) + store.stageWorkspaceSessionBeforeUnload(session('quit', 'editor')) + expect(titles(store)).toEqual({ moving: ['slept'], staying: ['quit'] }) + }) + }) + + it('keeps freezing through commit, so the retained rows survive for a downgrade', () => { + seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed')) + store.setWorkspaceSession(session('edited', 'editor')) + expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] }) + }) + + it('freezes nothing, with one warning, when the journal cannot be read', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const directory = wslMoveJournalDirectory(testState.dir) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'move-1.json'), '{not json') + loadWslMoveFence(testState.dir) + store.setWorkspaceSession(session('edited', 'editor')) + expect(titles(store)).toEqual({ moving: ['edited'], staying: ['edited'] }) + expect(warn).toHaveBeenCalledTimes(1) + }) +}) + +describe('WSL move session freeze: other session writers', () => { + beforeEach(() => { + seedWslMoveCutover(testState.dir, cutover('move-1', 'quiesced')) + }) + + it('keeps the frozen rows through a failed durable mutation that rolls the session back', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const refuse = () => { + throw new Error('disk refused') + } + const authority = ProfileStateSqliteAuthority.prototype + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(refuse) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce(refuse) + vi.spyOn(authority, 'writeSerializedState').mockImplementationOnce(refuse) + await expect( + store.runDurableMutation(() => { + store.setWorkspaceSession(session('mutated', 'editor')) + return { + value: undefined, + rollback: () => store.setWorkspaceSession(session('rolled-back', 'editor')) + } + }) + ).rejects.toThrow('disk refused') + expect(titles(store)).toEqual({ moving: ['slept'], staying: ['rolled-back'] }) + }) + + it('keeps the frozen rows when the repo is pruned', () => { + const state = getDefaultPersistedState(testState.dir) + state.workspaceSession = { + ...store.getWorkspaceSession(), + lastVisitedAtByWorktreeId: { [MOVING]: 1, [STAYING]: 1 } + } + pruneWorktreeStateForRepo(state, 'repo-1', null, () => undefined) + pruneWorktreeStateForRepo(state, 'repo-2', null, () => undefined) + expect(Object.keys(state.workspaceSession.tabsByWorktree)).toEqual([MOVING]) + }) + + it('keeps the frozen rows when the workspace owner is removed', () => { + store.removeWorkspaceSessionStateForWorktree(MOVING) + store.removeWorkspaceSessionStateForWorktree(STAYING) + expect(titles(store)).toEqual({ moving: ['slept'], staying: undefined }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.ts b/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.ts new file mode 100644 index 00000000000..2b78f73d426 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.ts @@ -0,0 +1,69 @@ +/** + * Once a move has paused a project, its workspaces' session stays exactly as the quiesce left it: + * that slept state is what the move carries to the host, so a later renderer write must neither + * change nor drop it. Every other workspace's rows, and the global fields, are taken as written. + */ +import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership' +import { + mergeWorkspaceSessionArrayField, + mergeWorkspaceSessionRecordField +} from '../../../shared/workspace-session-host-records' +import { + filterWorkspaceRows, + withoutWorkspaces, + workspaceIdsNamedByPartition +} from '../../../shared/workspace-session-stranded-partition-adoption' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { hasWslMoveFrozenSessions, isWslMoveWorkspaceSessionFrozen } from './wsl-move-scope-guard' + +export function keepWslMoveFrozenSessions( + next: WorkspaceSessionState, + prior: WorkspaceSessionState | undefined, + isFrozen: (workspaceId: string) => boolean +): WorkspaceSessionState { + const frozen = new Set() + for (const workspaceId of workspaceIdsNamedByPartition(next)) { + if (isFrozen(workspaceId)) { + frozen.add(workspaceId) + } + } + for (const workspaceId of prior ? workspaceIdsNamedByPartition(prior) : []) { + if (isFrozen(workspaceId)) { + frozen.add(workspaceId) + } + } + if (frozen.size === 0) { + return next + } + const merged = withoutWorkspaces(next, frozen) + if (!prior) { + return merged + } + const kept = filterWorkspaceRows(prior, (workspaceId) => frozen.has(workspaceId)) + for (const [field, ownership] of Object.entries(WORKSPACE_SESSION_FIELD_OWNERSHIP)) { + if (!isSessionField(field) || ownership === 'global' || ownership === 'hostPrivate') { + continue + } + if (ownership === 'worktreeArray') { + mergeWorkspaceSessionArrayField(merged, field, kept) + } else { + mergeWorkspaceSessionRecordField(merged, field, kept) + } + } + return merged +} + +function isSessionField(field: string): field is keyof WorkspaceSessionState { + return Object.hasOwn(WORKSPACE_SESSION_FIELD_OWNERSHIP, field) +} + +/** The store's write paths: a no-op unless some move has paused a project. */ +export function keepWslMoveFrozenSessionsForStore( + next: WorkspaceSessionState, + prior: WorkspaceSessionState | undefined +): WorkspaceSessionState { + if (!hasWslMoveFrozenSessions()) { + return next + } + return keepWslMoveFrozenSessions(next, prior, isWslMoveWorkspaceSessionFrozen) +} diff --git a/src/main/persistence/restoring-sessions/session-owner-removal.ts b/src/main/persistence/restoring-sessions/session-owner-removal.ts index 1cb7a44e004..1432a852c89 100644 --- a/src/main/persistence/restoring-sessions/session-owner-removal.ts +++ b/src/main/persistence/restoring-sessions/session-owner-removal.ts @@ -6,8 +6,15 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner' +import { isWslMoveWorkspaceSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard' import { cloneWorkspaceSessionState, deleteOwnerKeyedSessionFields } from './session-owner-fields' +/** Owners whose rows may go: a paused WSL move keeps its rows for a downgrade. */ +export function withoutWslMoveFrozenOwners(ownerKeys: ReadonlySet): ReadonlySet { + const kept = [...ownerKeys].filter((ownerKey) => !isWslMoveWorkspaceSessionFrozen(ownerKey)) + return kept.length === ownerKeys.size ? ownerKeys : new Set(kept) +} + // Scans the pane-key-keyed maps, the close records and the shutdown list once, removing // every entry owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose // tab was removed). Kept separate from the O(1) deletes so a batch prune scans each @@ -81,6 +88,9 @@ export function removeWorkspaceSessionOwnerEverywhere( ownerKey: string, options: { advanceTerminalTopologyRevision?: boolean } = {} ): void { + if (isWslMoveWorkspaceSessionFrozen(ownerKey)) { + return + } state.workspaceSession = removeWorkspaceSessionOwner(state.workspaceSession, ownerKey, options)! const partitions = state.workspaceSessionsByHostId if (!partitions) { @@ -134,8 +144,9 @@ export function removeWorkspaceSessionOwners( export function removeWorkspaceSessionOwnersEverywhere( state: Pick, - ownerKeys: ReadonlySet + allOwnerKeys: ReadonlySet ): void { + const ownerKeys = withoutWslMoveFrozenOwners(allOwnerKeys) if (ownerKeys.size === 0) { return } diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 756869d08d7..f5998264bc3 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -1,4 +1,4 @@ -import type { ExecutionHostId } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import type { PersistedState } from '../../../shared/persisted-state-types' import type { TerminalLeafMoveRequest, @@ -24,6 +24,7 @@ import { import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' +import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze' // The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and // the pane move; the close transform still lives in runtime/ and other writers move here later. @@ -147,6 +148,17 @@ export type TerminalTopologyCommitContext = { } /** Writes `next`, returning a restore that puts the prior value back unless a later write replaced it. */ +// A paused WSL move keeps its local rows as the quiesce left them. +function keepFrozen( + hostId: ExecutionHostId, + session: WorkspaceSessionState, + context: TerminalTopologyCommitContext +): WorkspaceSessionState { + return hostId === LOCAL_EXECUTION_HOST_ID + ? keepWslMoveFrozenSessionsForStore(session, context.getSession(hostId)) + : session +} + function writeRestorable(read: () => V, write: (value: V) => void, next: V): () => void { const prior = read() write(next) @@ -173,7 +185,7 @@ function commitLeafMove( writeRestorable( () => context.getSession(hostId), (value) => context.markDirty(assignWorkspaceSessionPartition(state, hostId, value)), - session + keepFrozen(hostId, session, context) ) ) const rekeyed = rekeyMovedLeafProfileRecords(state, request) @@ -214,7 +226,7 @@ function commitPaneChange( const restore = writeRestorable( () => context.getSession(hostId), (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), - change.session + keepFrozen(hostId, change.session, context) ) return { value: change.value, rollback: restore } } diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index 2382cb6a371..6987f8b2da4 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -3,7 +3,10 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/e import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' +import { + removeWorkspaceSessionOwners, + withoutWslMoveFrozenOwners +} from '../restoring-sessions/session-owner-removal' import { getExecutionHostIdFromWorktreeHostIdentity, getWorktreeIdFromHostIdentity, @@ -105,7 +108,10 @@ export function pruneWorktreeStateForRepo( const pruneLegacyLocalSession = hostId === null || hostId === LOCAL_EXECUTION_HOST_ID const pruneAllHostPartitions = hostId === null if (pruneLegacyLocalSession) { - state.workspaceSession = removeWorkspaceSessionOwners(state.workspaceSession, ownerKeysToPrune)! + state.workspaceSession = removeWorkspaceSessionOwners( + state.workspaceSession, + withoutWslMoveFrozenOwners(ownerKeysToPrune) + )! } if (state.workspaceSessionsByHostId) { for (const [partitionHostId, session] of Object.entries(state.workspaceSessionsByHostId)) { diff --git a/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts index 24b2b09a6b5..a2b11a8ef35 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { closeTestStores, createStore, testState } from '../persistence-test-harness' import type { Store } from '../persistence' import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' -import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' import { WslMoveProjectFencedError, loadWslMoveFence, @@ -71,7 +71,7 @@ beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-meta-')) store = createStore() loadWslMoveFence(testState.dir) - writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) + seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) }) afterEach(async () => { resetWslMoveFenceForTests() diff --git a/src/main/runtime/runtime-provisioned-root-adoption.test.ts b/src/main/runtime/runtime-provisioned-root-adoption.test.ts index 18addb123b8..364b3a90f30 100644 --- a/src/main/runtime/runtime-provisioned-root-adoption.test.ts +++ b/src/main/runtime/runtime-provisioned-root-adoption.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' -import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' import { WslMoveProjectFencedError, loadWslMoveFence, @@ -107,7 +107,7 @@ describe('adoptRuntimeProvisionedRoot', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-adopt-')) try { loadWslMoveFence(userDataPath) - writeWslMoveCutover( + seedWslMoveCutover( userDataPath, cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-vm'] }) ) diff --git a/src/main/window/worktree-sleep-request-relay.test.ts b/src/main/window/worktree-sleep-request-relay.test.ts new file mode 100644 index 00000000000..9b6a2c8f35f --- /dev/null +++ b/src/main/window/worktree-sleep-request-relay.test.ts @@ -0,0 +1,112 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeSleepRequest } from '../../shared/worktree-sleep-request' + +const ipcEmitter = new EventEmitter() +vi.mock('electron', () => ({ + ipcMain: { + on: (channel: string, listener: (...args: unknown[]) => void) => + ipcEmitter.on(channel, listener), + removeListener: (channel: string, listener: (...args: unknown[]) => void) => + ipcEmitter.removeListener(channel, listener) + } +})) + +const { requestWorktreeSleepFromRenderer, worktreeSleepTimeoutMs } = + await import('./worktree-sleep-request-relay') + +class FakeContents extends EventEmitter { + destroyed = false + readonly sent: WorktreeSleepRequest[] = [] + isDestroyed(): boolean { + return this.destroyed + } + send(_channel: string, request: WorktreeSleepRequest): void { + this.sent.push(request) + } +} + +class FakeWindow extends EventEmitter { + readonly webContents = new FakeContents() + isDestroyed(): boolean { + return false + } +} + +const respond = (sender: unknown, response: Record): void => { + ipcEmitter.emit('ui:worktreeSleepResponse', { sender }, response) +} + +let window: FakeWindow +beforeEach(() => { + ipcEmitter.removeAllListeners() + window = new FakeWindow() +}) +afterEach(() => vi.useRealTimers()) + +describe('requestWorktreeSleepFromRenderer', () => { + it("resolves with the renderer's failed workspaces once its own window acks", async () => { + const pending = requestWorktreeSleepFromRenderer(window, ['wt-1', 'wt-2']) + const [request] = window.webContents.sent + expect(request?.worktreeIds).toEqual(['wt-1', 'wt-2']) + + respond(new FakeContents(), { requestId: request?.requestId }) + respond(window.webContents, { requestId: 'other' }) + respond(window.webContents, { requestId: request?.requestId, failedWorktreeIds: ['wt-2'] }) + + await expect(pending).resolves.toEqual({ failedWorktreeIds: ['wt-2'] }) + expect(ipcEmitter.listenerCount('ui:worktreeSleepResponse')).toBe(0) + expect(window.listenerCount('closed')).toBe(0) + }) + + it('rejects with the error the renderer reports', async () => { + const pending = requestWorktreeSleepFromRenderer(window, ['wt-1']) + respond(window.webContents, { + requestId: window.webContents.sent[0]?.requestId, + error: 'persist failed' + }) + await expect(pending).rejects.toThrow('persist failed') + }) + + it.each([ + ['the window closes', (target: FakeWindow) => target.emit('closed')], + ['the renderer dies', (target: FakeWindow) => target.webContents.emit('render-process-gone')], + ['the renderer reloads', (target: FakeWindow) => target.webContents.emit('did-start-loading')] + ])('rejects renderer_unavailable when %s', async (_name, lose) => { + const pending = requestWorktreeSleepFromRenderer(window, ['wt-1']) + lose(window) + await expect(pending).rejects.toThrow('renderer_unavailable') + expect(ipcEmitter.listenerCount('ui:worktreeSleepResponse')).toBe(0) + expect(window.webContents.listenerCount('destroyed')).toBe(0) + expect(window.webContents.listenerCount('did-start-loading')).toBe(0) + }) + + it('rejects renderer_unavailable up front when the renderer is already gone', async () => { + window.webContents.destroyed = true + await expect(requestWorktreeSleepFromRenderer(window, ['wt-1'])).rejects.toThrow( + 'renderer_unavailable' + ) + expect(window.webContents.sent).toEqual([]) + }) + + it('times out instead of waiting forever', async () => { + vi.useFakeTimers() + const pending = requestWorktreeSleepFromRenderer(window, ['wt-1']) + let done = false + const settled = expect(pending.finally(() => (done = true))).rejects.toThrow( + 'worktree_sleep_timeout' + ) + await vi.advanceTimersByTimeAsync(29_999) + expect(done).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await settled + expect(ipcEmitter.listenerCount('ui:worktreeSleepResponse')).toBe(0) + }) + + it('scales the wait with the workspaces, capped at five minutes', () => { + expect(worktreeSleepTimeoutMs(0)).toBe(20_000) + expect(worktreeSleepTimeoutMs(3)).toBe(50_000) + expect(worktreeSleepTimeoutMs(28)).toBe(300_000) + expect(worktreeSleepTimeoutMs(500)).toBe(300_000) + }) +}) diff --git a/src/main/window/worktree-sleep-request-relay.ts b/src/main/window/worktree-sleep-request-relay.ts new file mode 100644 index 00000000000..2d886179d75 --- /dev/null +++ b/src/main/window/worktree-sleep-request-relay.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto' + +import { ipcMain } from 'electron' +import type { BrowserWindow } from 'electron' +import type { + WorktreeSleepRequest, + WorktreeSleepResponse +} from '../../shared/worktree-sleep-request' + +const WORKTREE_SLEEP_BASE_TIMEOUT_MS = 20_000 +const WORKTREE_SLEEP_PER_WORKSPACE_MS = 10_000 +const WORKTREE_SLEEP_MAX_TIMEOUT_MS = 5 * 60_000 + +/** One request sleeps every workspace of a project in turn, so the wait grows with them. */ +export function worktreeSleepTimeoutMs(workspaceCount: number): number { + return Math.min( + WORKTREE_SLEEP_BASE_TIMEOUT_MS + WORKTREE_SLEEP_PER_WORKSPACE_MS * workspaceCount, + WORKTREE_SLEEP_MAX_TIMEOUT_MS + ) +} + +export type WorktreeSleepResult = { failedWorktreeIds: string[] } + +/** The parts of a BrowserWindow the relay touches. */ +export type WorktreeSleepRendererWindow = { + isDestroyed(): boolean + readonly webContents: { + isDestroyed(): boolean + send(channel: string, request: WorktreeSleepRequest): void + once(event: 'destroyed', listener: () => void): unknown + once(event: 'render-process-gone', listener: () => void): unknown + once(event: 'did-start-loading', listener: () => void): unknown + removeListener(event: 'destroyed', listener: () => void): unknown + removeListener(event: 'render-process-gone', listener: () => void): unknown + removeListener(event: 'did-start-loading', listener: () => void): unknown + } + once(event: 'closed', listener: () => void): unknown + removeListener(event: 'closed', listener: () => void): unknown +} + +// Why: no call site passes a real window yet; keep the narrow type honest against Electron's. +const browserWindowFits: BrowserWindow extends WorktreeSleepRendererWindow ? true : never = true +void browserWindowFits + +/** + * Sleep runs in the renderer, which owns the slept-pane records; this waits until it has slept + * the workspaces and persisted their session. Rejects `renderer_unavailable` or + * `worktree_sleep_timeout`; there is no headless fallback. + */ +export async function requestWorktreeSleepFromRenderer( + mainWindow: WorktreeSleepRendererWindow, + worktreeIds: readonly string[] +): Promise { + if (mainWindow.isDestroyed()) { + throw new Error('renderer_unavailable') + } + const webContents = mainWindow.webContents + if (webContents.isDestroyed()) { + throw new Error('renderer_unavailable') + } + const requestId = randomUUID() + return new Promise((resolve, reject) => { + let settled = false + const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable')) + const finish = (outcome: Error | WorktreeSleepResult): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + ipcMain.removeListener('ui:worktreeSleepResponse', onResponse) + mainWindow.removeListener('closed', onRendererUnavailable) + webContents.removeListener('destroyed', onRendererUnavailable) + webContents.removeListener('render-process-gone', onRendererUnavailable) + webContents.removeListener('did-start-loading', onRendererUnavailable) + if (outcome instanceof Error) { + reject(outcome) + } else { + resolve(outcome) + } + } + const timeout = setTimeout( + () => finish(new Error('worktree_sleep_timeout')), + worktreeSleepTimeoutMs(worktreeIds.length) + ) + const onResponse = (event: Electron.IpcMainEvent, response: WorktreeSleepResponse): void => { + // Why: request ids are visible to renderer code; only the selected window may answer. + if (event.sender !== webContents || response.requestId !== requestId) { + return + } + finish( + response.error + ? new Error(response.error) + : { failedWorktreeIds: response.failedWorktreeIds ?? [] } + ) + } + ipcMain.on('ui:worktreeSleepResponse', onResponse) + mainWindow.once('closed', onRendererUnavailable) + webContents.once('destroyed', onRendererUnavailable) + webContents.once('render-process-gone', onRendererUnavailable) + // A reload discards the renderer's pending Sleep, so it would never answer. + webContents.once('did-start-loading', onRendererUnavailable) + const request: WorktreeSleepRequest = { requestId, worktreeIds: [...worktreeIds] } + try { + webContents.send('ui:worktreeSleepRequest', request) + } catch { + finish(new Error('renderer_unavailable')) + } + }) +} diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 6baa98ad6bb..333ebd8a3bb 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -73,6 +73,15 @@ export const uiClipboardAndWindowControlsApi = { ipcRenderer.on('ui:sleepWorktree', listener) return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) }, + onWorktreeSleepRequest: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, request: Parameters[0]) => + callback(request) + ipcRenderer.on('ui:worktreeSleepRequest', listener) + return () => ipcRenderer.removeListener('ui:worktreeSleepRequest', listener) + }, + respondWorktreeSleep: (response) => { + ipcRenderer.send('ui:worktreeSleepResponse', response) + }, onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => callback(data) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index a00a24dbfe4..963be524031 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -48,6 +48,10 @@ import type { SessionTabCloseRequest, SessionTabCloseResponse } from '../../shared/session-tab-close' +import type { + WorktreeSleepRequest, + WorktreeSleepResponse +} from '../../shared/worktree-sleep-request' export type CloseActiveTabPayload = { sourceId: string } @@ -262,6 +266,8 @@ export type UiCommandEventApi = { onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void respondTerminalTabClose: (response: TerminalTabCloseResponse) => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void + onWorktreeSleepRequest: (callback: (request: WorktreeSleepRequest) => void) => () => void + respondWorktreeSleep: (response: WorktreeSleepResponse) => void onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void onSystemResumed: (callback: () => void) => () => void diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index c38f0d4e36d..34497baf390 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -48,7 +48,11 @@ vi.mock('@/lib/worktree-sleep-intent', () => ({ withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() })) -import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' +import { + runSleepWorktree, + runSleepWorktrees, + sleepWorktreesReportingFailures +} from './sleep-worktree-flow' describe('runSleepWorktree', () => { beforeEach(() => { @@ -310,6 +314,18 @@ describe('runSleepWorktree', () => { ) }) + it('reports failed worktrees to a caller that answers for them, without a toast', async () => { + mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => + worktreeId === 'wt-1' ? Promise.reject(new Error('first failed')) : Promise.resolve() + ) + + const outcome = await sleepWorktreesReportingFailures(['wt-1', 'wt-2']) + + expect(outcome.failedWorktreeIds).toEqual(['wt-1']) + expect(outcome.errors).toHaveLength(1) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + it('sleeps multiple worktrees and clears active only once when included', async () => { mocks.state.activeWorktreeId = 'wt-2' diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 1e414a81800..24fb2a707b6 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -136,8 +136,34 @@ function describeSleepFailure(error: unknown): string { } export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise { + const { errors } = await sleepWorktreesReportingFailures(worktreeIds) + if (errors.length > 0) { + // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. + toast.error( + worktreeIds.length === 1 + ? translate( + 'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671', + 'Failed to sleep workspace' + ) + : translate( + 'auto.components.sidebar.sleep.worktree.flow.c460fecc4a', + 'Failed to sleep some workspaces' + ), + { + description: errors.join('\n') + } + ) + } +} + +export type WorktreeSleepOutcome = { failedWorktreeIds: string[]; errors: string[] } + +/** The sleep without the toast, for callers that report failures themselves. */ +export async function sleepWorktreesReportingFailures( + worktreeIds: readonly string[] +): Promise { if (worktreeIds.length === 0) { - return + return { failedWorktreeIds: [], errors: [] } } const { activeWorktreeId, @@ -214,21 +240,5 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise setActiveWorktree(sleptActiveWorktreeId) } } - if (errors.length > 0) { - // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. - toast.error( - worktreeIds.length === 1 - ? translate( - 'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671', - 'Failed to sleep workspace' - ) - : translate( - 'auto.components.sidebar.sleep.worktree.flow.c460fecc4a', - 'Failed to sleep some workspaces' - ), - { - description: errors.join('\n') - } - ) - } + return { failedWorktreeIds: [...failedWorktreeIds], errors } } diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts index a44f474a6ac..2130c5e2b6a 100644 --- a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -2,7 +2,10 @@ import { createRuntimeFilePositionQueue } from './runtime-file-position' import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice' import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' import { detectLanguage } from '@/lib/language-detect' -import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' +import { + runSleepWorktree, + sleepWorktreesReportingFailures +} from '@/components/sidebar/sleep-worktree-flow' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal' @@ -133,6 +136,30 @@ export function registerMobileAndTerminalCloseIpcBridge( }) ) + // Why optional: an older preload lingering through an in-place reload lacks the channel. + if (window.api.ui.onWorktreeSleepRequest) { + unsubs.push( + window.api.ui.onWorktreeSleepRequest(({ requestId, worktreeIds }) => { + void (async () => { + const { failedWorktreeIds } = await sleepWorktreesReportingFailures(worktreeIds) + // Main waits on the ack, so the slept state must be durable before it. + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + window.api.ui.respondWorktreeSleep({ requestId, failedWorktreeIds }) + })().catch((error: unknown) => { + window.api.ui.respondWorktreeSleep({ + requestId, + error: error instanceof Error ? error.message : 'worktree_sleep_failed' + }) + }) + }) + ) + } + unsubs.push( window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { // Why: a phone opened this worktree; wake its slept agents without changing the desktop's worktree/tab/view. diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index b5cf51fa3d9..0482f12460e 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -97,6 +97,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'ui.onToggleStatusBar', 'ui.onToggleWorktreePalette', 'ui.onWorktreeHistoryNavigate', + 'ui.onWorktreeSleepRequest', 'updater.onClearDismissal', 'updater.onStatus', 'workspaceSpace.onProgress', @@ -164,6 +165,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ui.onCloseTerminal', 'ui.onTerminalTabCloseRequest', 'ui.onSleepWorktree', + 'ui.onWorktreeSleepRequest', 'ui.onResumeSleepingAgents', 'updater.onStatus', 'updater.onClearDismissal', diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 9ac0c927e7d..993e15d5a24 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -277,6 +277,8 @@ export function createWebUiApi(): NonNullable['ui']> { onTerminalTabCloseRequest: () => noopUnsubscribe, respondTerminalTabClose: () => {}, onSleepWorktree: () => noopUnsubscribe, + onWorktreeSleepRequest: () => noopUnsubscribe, + respondWorktreeSleep: () => {}, // Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web. onResumeSleepingAgents: () => noopUnsubscribe, onTerminalZoom: () => noopUnsubscribe, diff --git a/src/shared/workspace-session-stranded-partition-adoption.ts b/src/shared/workspace-session-stranded-partition-adoption.ts index 733c5d9f9d1..d5ab457702a 100644 --- a/src/shared/workspace-session-stranded-partition-adoption.ts +++ b/src/shared/workspace-session-stranded-partition-adoption.ts @@ -266,6 +266,14 @@ function entryWorkspaceResolver( export function withoutWorkspaces( base: WorkspaceSessionState, workspaceIds: ReadonlySet +): WorkspaceSessionState { + return filterWorkspaceRows(base, (workspaceId) => !workspaceIds.has(workspaceId)) +} + +/** The scoped rows of the workspaces kept, by the same routing; global fields stay as they are. */ +export function filterWorkspaceRows( + base: WorkspaceSessionState, + keepsWorkspace: (workspaceId: string) => boolean ): WorkspaceSessionState { const worktreeIdByTabId = buildWorktreeIdByTabId(base) const worktreeIdByFileId = buildWorktreeIdByFileId(base) @@ -273,7 +281,7 @@ export function withoutWorkspaces( for (const field of SESSION_FIELDS) { const workspaceOf = entryWorkspaceResolver(field, worktreeIdByTabId, worktreeIdByFileId) const keeps = (key: string, entry: unknown): boolean => - !workspaceIds.has(normalizeWorkspaceSessionKeyToWorkspaceId(workspaceOf?.(key, entry) ?? '')) + keepsWorkspace(normalizeWorkspaceSessionKeyToWorkspaceId(workspaceOf?.(key, entry) ?? '')) const value: unknown = base[field] if (!workspaceOf || !value) { continue diff --git a/src/shared/worktree-sleep-request.ts b/src/shared/worktree-sleep-request.ts new file mode 100644 index 00000000000..f6a7d8e2527 --- /dev/null +++ b/src/shared/worktree-sleep-request.ts @@ -0,0 +1,12 @@ +/** Main asks the renderer to Sleep workspaces and waits until their slept state is durable. */ +export type WorktreeSleepRequest = { + requestId: string + worktreeIds: string[] +} + +export type WorktreeSleepResponse = { + requestId: string + /** Workspaces whose Sleep failed; they stay awake and retryable. */ + failedWorktreeIds?: string[] + error?: string +} diff --git a/tests/e2e/wsl-move-fence.spec.ts b/tests/e2e/wsl-move-fence.spec.ts index 3d6f8cbddd3..53b9f4dcfdd 100644 --- a/tests/e2e/wsl-move-fence.spec.ts +++ b/tests/e2e/wsl-move-fence.spec.ts @@ -14,6 +14,10 @@ import { waitForSessionReady } from './helpers/store' import { createRestartSession } from './helpers/orca-restart' import { wslMoveCutoverFixture } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' import { writeWslMoveCutover } from '../../src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { + WSL_MOVE_CUTOVER_PHASES, + type WslMoveCutoverPhase +} from '../../src/shared/wsl-move/wsl-move-cutover' test.use({ seedTestRepo: false }) @@ -134,7 +138,7 @@ test('a WSL move journal fences the moving project, then hides it once the host await session.close(launched.app) launched = undefined - const record = (phase: 'source-fenced' | 'destination-committed') => + const record = (phase: WslMoveCutoverPhase) => wslMoveCutoverFixture( 'move-1', phase, @@ -162,7 +166,17 @@ test('a WSL move journal fences the moving project, then hides it once the host ) } - writeWslMoveCutover(session.userDataDir, record('source-fenced')) + // The journal only moves forward one phase at a time, as a real move writes it. + let journaled = -1 + const advanceTo = (phase: WslMoveCutoverPhase) => { + const target = WSL_MOVE_CUTOVER_PHASES.indexOf(phase) + for (journaled += 1; journaled <= target; journaled += 1) { + writeWslMoveCutover(session.userDataDir, record(WSL_MOVE_CUTOVER_PHASES[journaled])) + } + journaled = target + } + + advanceTo('source-fenced') launched = await session.launch() await waitForSessionReady(launched.page) await expectMovingRefused(launched.page) @@ -173,7 +187,7 @@ test('a WSL move journal fences the moving project, then hides it once the host await session.close(launched.app) launched = undefined - writeWslMoveCutover(session.userDataDir, record('destination-committed')) + advanceTo('destination-committed') launched = await session.launch() await waitForSessionReady(launched.page) await expectMovingRefused(launched.page)