mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Land the WSL project mover dark (merges only after verify-15b, the flag-off audit and green CI) (#34059)
* test(orcad-migration): pin the SSH manifest through the real dormant collectors Co-Authored-By: Claude <noreply@anthropic.com> * refactor(orcad-migration): read the dormant-state export through a source-kind scope A local-WSL scope lives on host local and owns only its projects, so host-wide matches stay an SSH whole-host rule. SSH bytes are pinned by the collector golden. Co-Authored-By: Claude <noreply@anthropic.com> * feat(wsl-mover): export a local WSL move's signed manifest with its dormant state (D1b-3c) Collects the scoped projects, their groups and every dormant-state kind through the local-WSL export scope, rewrites UNC paths to the distro's, and returns blockers for anything the collectors can't carry instead of dropping it. Automations move disabled, as the wire requires, and come back as a list to re-enable on the host after commit. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): reset an interrupted move to zero, or finish it once the host proves a commit Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): abort a move by its identity when its manifest is too large for a request Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): cap a new move's manifest below the request limit, and move forward on a by-reference commit only once the host flushed it Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): remove only the newest move of a distro, so its chain stays readable Co-Authored-By: Claude <noreply@anthropic.com> * feat(runtime): clients read a migrated pane's scrollback from its host (D1b-6d-1) * fix(runtime): hold a remote pane only when its host is known to serve scrollback, and never read bytes in render (D1b-6d-1) * fix(runtime): route the scrollback read by the tab's own host, never the focused one (D1b-6d-1) * fix(runtime): resolve the scrollback host through resolveOwner and use the shared RPC error check, after main moved both (D1b-6d-1) * feat(wsl-mover): a moved project's existing chats stay listed and resumable on this computer (D1b-7a) * fix(wsl-mover): moved-project chats go through the layout owner instead of writing layout directly (D1b-7a) * test(wsl-mover): run the moved-project chat runtime test in the Node runtime (D1b-7a) * test(wsl-mover): read the hidden local row through main's per-host hidden-repo API (D1b-7a) * feat(wsl-mover): rewrite the desktop's routing and seed the host's session after a move (D1b-7c) Registers the main-side desktop rewrite stores ui-routing, session-globals and host-session-seed. Adds a +2 main layout-writer baseline line for wsl-move-main-rewrite-stores.ts: these are one-shot, ledgered move rewrites, and no LayoutCommand can set focus or seed a runtime host's partition. * fix(wsl-mover): finish the main rewrite before the window loads, keep one copy of each moved tab, and copy instead of move (D1b-7c) * feat(wsl-move): rewrite renderer stores for moved projects and keep their UI through catalog prunes (D1b-7d) Registers the renderer-side desktop rewrite stores quick-open-history, file-view-preferences and notebook-environments. The renderer copies each moved workspace's entry to its WSL host key, marks it applied in the same storage, and records the ledger entry; main requests a storage flush first and accepts only the main window. Committed moves are re-listed for re-application at every startup. Catalog prunes keep filters, hook trust and setup-script dismissals of moved repos. * fix(wsl-move): import the migration manifest types from main's orcad-migration folder (D1b-7d) * fix(wsl-move): import wslUncPathToLinux from shared wsl-paths in the manifest exporter, where 7d moved it (D1b-7d) * feat(wsl-mover): rewrite a moved project's orchestration history on the desktop (D1b-7e) Settled local worker dispatches and their worker-terminal rows in a moved project take the WSL host's worktree id, in one ledgered, idempotent store. Chat workers, live rows, federated attachments and JSON receipts stay as written; automations keep their unchanged local copy (the scheduler only advances it). Per-table dispositions for orchestration.db. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): hold the orchestration store while a worker in scope is live; skip federated rows (D1b-7e review) Co-Authored-By: Claude <noreply@anthropic.com> * feat(wsl-mover): copy in-app browser logins into the WSL host on a move (D1b-7f) A committed WSL move now copies each in-app browser profile's cookies once into the WSL host's stable route partition (owner decision 3a), merging without overwriting what the host jar holds and never touching the source. Where that is infeasible (no deployment link so no stable partition, the destination is not a WSL host or is gone, or the partition name is held by another identity) the host starts fresh and the move journal records why (3b). Registered as the ledgered browser-cookies rewrite store; it runs only once browser sessions and an app window exist. WSL_MOVE_DESKTOP_REWRITE_REGISTRY_COMPLETE stays false. * feat(wsl-mover): retain moved source rows, reconcile them on start, and plan delta moves (D1b-8) * fix(wsl-mover): keep the retained mark out of the WSL move manifest (D1b-8) * feat(wsl-mover): the move driver runs a WSL move end to end, with production gate facts and journaled restore folders One entry point (and preview/start/cancel/listUnfinished IPC for D1b-9) plans the move, gates terminals on the hook server's agent status, the close-tab process probe, running automation runs and live dispatches, pauses and proves exit, stages, sends blobs, commits only from blobs-transferred (#29825), and finishes on the desktop. Any failure resets to zero or goes forward when the host committed. Restore folders and the automations to turn back on are kept in the journal; the host turns those automations back on after commit, retried until it confirms. A real-PTY test pauses daemon shells, and runs on a real distro in the WSL orcad lane. * feat(wsl-mover): move dialog with preview, blockers, progress and result behind the internal WSL hosts gate (D1b-9) * fix(wsl-mover): move dialog hides instead of blocking, follows runs after a reload, and lets a start settle an unfinished move (D1b-9 review) * fix(wsl-mover): word an unfinished earlier move as still finishing (D1b-9) * test(wsl-mover): type the e2e's fake move state instead of reading it through Reflect (D1b-9) * WIP(wsl-mover): 7f-2 registry complete, coverage proof, startup re-apply (D1b-7f-2) * test(wsl-mover): registry-complete lifecycle, stale-focus restart, unload re-apply (D1b-7f-2) * fix(wsl-mover): share the session focus write so the re-apply adds no layout writer (D1b-7f-2) * fix(wsl-mover): redo additive rewrites once at a stale window's unload; behavioural coverage proof (D1b-7f-2 review) * test(wsl-mover): the IPC record test now finishes the rewrite with the registry complete (D1b-7f-2) * fix(wsl-mover): fence worktree-less spawns by cwd and re-prove terminal exit before commit (R46, WIP) * test(wsl-mover): pin the commit re-proof, browser jar identity and repeated move generations (R45, R46, R47) * fix(wsl-mover): fence any spawn by its folder and name the re-proof's reason (R46 review) * fix(wsl-mover): a terminal in a known folder outside the move never blocks it (R46 follow-up) * test(wsl-mover): kill the real move driver at every point against a real host store; abort a reset move's leftover stage on reconnect Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): carry unsaved drafts into the moved project; round-trip every fixture through the real export, driver and host import Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): reset a move waiting on a distro that is proven unregistered; test updates mid-move (R41), lost distros (R38) and two desktops (R39) Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): list the D1b-10 real-SQLite mover tests Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): type the fault test's journal hook without a cast Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): reset for a lost distro even when its kept host fails to connect, never for a renamed one; compare filled-in repo keys only where the source lacked them Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): carry markdown frontmatter visibility and closed-terminal-tab tombstones into the moved project Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): read the persisted stage through a typed record, not Reflect Co-Authored-By: Claude <noreply@anthropic.com> * feat(telemetry): report how WSL moves end, by phase and reason code (R43) Co-Authored-By: Claude <noreply@anthropic.com> * fix(telemetry): report a pause refusal's reason, an outcome for a thrown run, and only real transport errors as unreachable Co-Authored-By: Claude <noreply@anthropic.com> * fix(agent-hooks): a WSL host's orcad never removes the hook entries the WSL bridge shares (R42) Co-Authored-By: Claude <noreply@anthropic.com> * fix(agent-hooks): a WSL host's Codex account-home launch with hooks off skips too; mark the R42 guard to drop with the bridge Co-Authored-By: Claude <noreply@anthropic.com> * feat(wsl-mover): "Use on this computer again" brings a moved project back as it was before the move Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): judge a bridge WSL pane's foreground inside its distro On Windows every live bridge pane's own process is wsl.exe, so the busy-terminal gate read every open WSL terminal as running a program and refused the move. Read the pane's terminal foreground process group inside the distro (processes carry ORCA_PANE_KEY): only shells means idle, anything else blocks, and an unreadable guest stays unverifiable. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): only the selected projects' session rows can refuse a WSL move The session eligibility check treated host local's whole partition as the move's, as the SSH conversion does. So an agent session or unsaved draft in any other project on this desktop, or in a project an earlier move retained, refused every later WSL move from that distro. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): sign the manifest as the journal and the host read it The digest was computed over in-memory rows. A value with toJSON (a Date on a row added this session) canonicalized to {} when signing but to its string once written to the journal or sent to the host, so the first move after adding a project reset with orcad_migration_manifest_digest_mismatch until a restart reloaded the row. Normalize through JSON before signing. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): carry a slept pane's scrollback from its daemon checkpoint The move sleeps every pane first, and a slept pane's history lives only in the daemon's checkpoint, never in a renderer snapshot. The export carried renderer snapshots only, so moved terminals opened empty. After Sleep the driver reads each paused pane's checkpoint (as cold restore would replay it) and the export carries it under the pane's own snapshot ref; the snapshot channel serves those bytes, checked against the signed hash. SSH conversions pass no reader and are unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): say why when a move can't be prepared, instead of a bare reset An export or journal failure after the pause (a digest mismatch, on awin) was rethrown and shown as "everything was put back" with no reason. Nothing is journaled yet at that point, so refuse with the error named instead. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): retry a renderer store main refused to record The renderer applied a store and wrote its marker, but ignored a refused ledger record (gated-off, failed, not-main-window), so a committed move stayed unfinished and refused every later move of the distro until the next app start. Log the reason and retry with backoff until it lands. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): a WSL pane is idle only when its shell has nothing else Review of #32343: reading only the terminal's foreground group let background jobs, nohup and disowned work, setsid daemons and a script waiting at read pass as idle, and the move's Sleep would end them. Scan the pane shell's subtree, its session and every process carrying its key; any non-shell process, or a shell running a script, blocks. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): never leave behind an agent whose key may be the selected project Review of #32343: scoping the session check to the selected projects let an unqualified key for a repo id another host also registers fall out of both the transfer and the refusal. Such a key now refuses. Adds tests that fail on the old code: another project's live agent with its tab open and its draft don't refuse, the same kinds of state in the selected project still do. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): refuse an unreadable slept checkpoint, prefer it, and spool it to disk Review of #32343: - A checkpoint behind an unreadable log (or an unreadable one) refuses with the pane named instead of carrying stale history. - For a pane the move just slept the checkpoint is the newest copy, so it wins over an older renderer buffer or snapshot. - Buffers spool to a per-move folder and only their hashes and sizes stay in memory; the snapshot channel reads them by offset. The folder is removed whatever the outcome. - A journal write that landed before it threw is reset, so no orphan record blocks the next move. awin's checkpoints were 310-615 KB, well inside the 5 MB per-pane cap, so nothing there is trimmed. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): retry a failed listing, never a record another window was refused Review of #32343: not-main-window can't clear by waiting, while a listing failure is transient. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): sign the manifest as the host parses it The likelier cause of awin's digest mismatch: the signature covered the exported objects, but the manifest parser keeps only the fields it knows, so a session field it drops (one a sleep can write) left the journal and the host hashing something else. Sign the parsed form, and warn when the parser dropped anything, since such a field does not move. Co-Authored-By: Claude <noreply@anthropic.com> * chore(wsl-mover): split the journal step out of the driver, and format The review fixes took wsl-move-driver.ts past the line limit; the export-and-journal step moves to its own module unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): a slept pane passes the busy check; say what each move dropped or lacked Re-run on awin (c5d263c77c): - Every slept pane read unverifiable: the distro was asked about panes whose wsl.exe had already exited, and found no shell. Only a pane the host still sees running is asked now. - A live pane read unverifiable when the WSL relay above its shell carried the pane key too; the pane's shell is now the topmost tagged shell. - The parser-dropped-fields warning names the paths, and the slept-history reader logs how many panes it carried, could not read, or found no checkpoint for, so the next run says why a moved terminal opened empty. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): read the history of panes slept before the move too verify-3 on awin: panes the user slept earlier in the session have no live terminal, so the move listed 0 of 0 panes and moved them empty. Their layout still names the session whose checkpoint holds the history; read those too, live panes first. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): moved panes paint their history, slept panes are found without a reload, and an older undo names the newer move - A moved tab's first mount reads its history from the host even before this window has the host's status; it used to mount empty and never ask again. - The window reports the panes it slept (main retires their binding on Sleep), checked against the moved workspaces before their history is read. - A tab's Windows shell choice is dropped on purpose, not by the parser. - "Use on this computer again" on an older move names the projects of the newer one to bring back first. * fix(wsl-mover): moved panes read their history, an idle agent passes, refusals name tabs and show - The manifest carries no desktop PTY binding: the host kept them, so the client took each moved pane for a live terminal, skipped the scrollback read and opened a fresh prompt. - Progress counts the terminal history it sends while staging, not only the blobs. - "Use on this computer again" shows a refusal in a dialog; the toast sat behind the update notice. - Unreadable slept history names the tab by its title and is refused in the preview too. - A bridge pane whose one job is an agent CLI lets the hook status decide: idle passes, working blocks. * fix(wsl-mover): a split agent tab's shell panes restart as shells, not fresh agents A moved (or any headless-restored) agent tab with a plain shell split beside the agent pane brought every split back as a new agent: the start path used the tab-wide launchAgent for each pane that had no recorded session. Tab-wide launch evidence now applies only to a tab's sole pane, as the renderer already treats it. Found in the D1b-10 real-host run on awin (report bug 6). Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): restore folders of panes slept before the move; keep browser tabs D1b-10 real-host run on awin (report bug 7, with bug 8 pinned), and verify-3: - A pane slept before the move had no live terminal, so its last folder never reached the restore hints and it reopened at the project root (verify-3 lost `deep/`). The pause step now adds the panes the saved session or the window still binds, through the slept-pane listing that already feeds their history, so hints and history read the same panes. - The move pauses projects through Sleep, which closes a workspace's browser tabs, so a moved project lost its in-app browser tab. A move's Sleep request now asks the renderer to keep them; an older renderer ignores the field. - A moving project's unsaved draft carries (the host-session seed lays it back); a test pins that it never refuses the move. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): sweep journal writes that died before their rename A killed journal write left <id>.json.<pid>.<ts>.tmp in the move journal forever (D1b-10 real-host run, report bug 10). Once per directory and process, the journal now removes another process's temporaries older than a minute; its own writes and a write another instance may still be finishing are kept. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): move dialog and add-host fixes from the real-host run D1b-10 real-host run on awin, report bug 9: - A host that will not connect now refuses as host-unavailable, in the preview and at start, instead of "Orca could not check these projects" / an unexpected error. - A run that ends without committing reports its result on the progress channel, so a window that only follows the run (reloaded mid-move, or started elsewhere) leaves "Moving… Preparing" for the result instead of sticking there until a reload. - The preview counts terminal panes, as the terminal blockers do, not tabs. - Small transfers read in B or KB, not "0.0 MB of 0.0 MB". - A distro that stopped after the add-host list was read refreshes the list, so the button becomes Start and add, with a message that says so. - The add-host copy no longer says bridge projects keep working "as they are" with no mention that they can move to the host. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): a sleeping WSL pane keeps its shell's folder Bash on the bridge reports no folder, so a slept pane's checkpoint kept its spawn folder and the move reopened every pane at the project root. Before the final checkpoint of a Sleep, read the shell's folder inside the distro (the busy check's probe, now with /proc/<pid>/cwd) and store it. Internal WSL hosts gate and Windows only. * fix(wsl-mover): moved browser tabs and drafts survive the moving window The window open across a move saved its mirror of the host's tabs over the host partition, and the unload's redo skipped any workspace that mirror named, so the seed's browser tab and draft never came back. The redo now adds the carried browser tabs and this desktop's drafts beside that mirror, never replacing what it holds. A seeded draft is bound to the host, as the SSH move's draft rename binds it. * fix(wsl-mover): a pane whose only history is a stored snapshot cold-restores it for every client A moved (or imported) pane carries its history only as a stored scrollback snapshot, which no paired client's mirrored layout names, so the host started a fresh shell and every desktop showed an empty prompt. Before starting such a pane, the host now seeds a terminal session from the snapshot, so the start cold-restores it like any interrupted session. Reproduced end to end in wsl-move-host-pane-history.spec.ts. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): seed history only for WSL-moved local panes, once per pane Review of the first version: it seeded any pending pane with a saved snapshot, including SSH panes (which would be asked to reattach a session the remote never had) and ordinary panes. Now only panes of a project a committed WSL move brought here (its import receipt names it) and never an SSH workspace. The session id is the pane's own, so a later start reuses the folder the pane has since written, or its clean exit, and never replays the stale snapshot; meta.json is written last, by rename. Tests pin the gate, the reuse, and the legacy history restore the seed relies on; the e2e adds the not-moved control. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): seed only panes the move carried; show history once; redo a cut-short seed Second review: narrow the seed from per-repo to per-pane (a tab created after the move's import is the host's own), redo a seed a crash left without meta.json, and prove on a windowless host (as the distro's orcad is) that the desktop paints the moved history exactly once - its mirrored layout never names the snapshot, so it never reads it too - and that the moved browser tab is listed. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): refuse "Use on this computer again" while files are unsaved An unsaved file's beforeunload silently vetoed main's reload after a release, so the window kept the moved layout with panes bound to dead terminals (awin verify-5). The renderer now asks for those files to be saved first; main logs a vetoed reload. Co-Authored-By: Claude <noreply@anthropic.com> * chore(wsl-mover): log where an open pane's history, folder and busy verdict come from awin verify-5 saw an open pane carry 140 B after 3,000 lines, reopen at its root, and stay "cannot check" after its job was killed elsewhere. None reproduces off Windows, since all three run through wsl.exe; these lines name the gap on the next run. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): an open pane keeps its shell folder through a move Bash on the bridge reports no folder, so an open pane's checkpoint kept only its spawn folder and `cd deep/er` reopened at the project root (awin verify-5). The pause now reads each live pane's shell folder inside its distro before Sleep ends the shell, and the restore hint prefers it. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): carry a slept pane's history once, not twice Cold restore fills scrollbackAnsi from snapshotAnsi for a normal screen, so both hold the whole buffer; the move concatenated them, carrying every line twice, and the per-pane cap then kept only the newest ~1,040 of 2,000 lines (awin verify-6). The move now carries the normal buffer once, as cold restore replays it. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): ask the distro when Windows can't observe a pane after a relaunch After an app relaunch, Windows reads a reattached bridge pane's wsl.exe children as unverifiable, so every live bridge pane stayed "cannot check" and blocked every move until it was slept (awin verify-6). The gate now asks the distro about panes Windows cannot observe too; only a pane whose shell the distro also can't find stays unverifiable. Also formats the earlier commits' tests. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): name a terminal's pane after a relaunch before judging it A terminal the runtime rediscovers after a relaunch carries no pane key until a layout names it again, and the gate read every paneless terminal as unverifiable, so no move could start after a restart (awin verify-6). The gate and the pause now take the pane main's PTY bookkeeping binds the terminal to. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): read manifest diff paths through a typed record, not Reflect.get Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): type the slept-pane modes fixture instead of asserting it Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): the move gate reads panes a relaunched window has not reopened After an app relaunch, every live WSL bridge pane read "cannot check" and blocked every move (awin verify-6, item 5). Root cause: the daemon adapter's hasPty only knows sessions this app process spawned or reattached. A pane the relaunched window had not reopened is still running in the daemon and listed by the runtime, but the inspection pre-check read hasPty=false as terminal_gone, so the probe was unverifiable. Reproduced on macOS with a two-launch e2e: an idle pane in an unopened workspace inspected as {verdict:'unverifiable', reason:'terminal_gone'} after the relaunch. The same run showed the runtime still holds the pane's original paneKey, so the paneKey theory is refuted. The gate now passes includeUnattached, which skips that pre-check and asks the daemon, which still refuses a session it no longer runs. Other callers keep the old behaviour. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): an idle stub agent passes the move gate once its Stop arrives awin verify-6 item 5d was inconclusive: p4's stub agent answered, but the preview stayed "agent-working". Replaying the D1b-10 stub's hook payloads through the real hook server and the gate's fact collection shows Stop settles the pane to done and the gate reads ready, so the Stop path is not at fault. The run's screenshot (ev/p4-agent2.png) shows the likely cause: the harness launched the stub twice in p4, and one instance sat at its prompt having fired only SessionStart. The stub fires SessionStart at launch, which Orca's Codex normalizer treats as a turn start, so a stub never given a prompt reads as working. No product change. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): an unreachable daemon reads as unverifiable for the move gate Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): browserless host e2e and stale-window unit test for moved browser tabs and drafts The distro's server has no browser of its own; an e2e-only switch (never in a packaged app) starts the headless server the same way. With the desktop's per-host copy as a move leaves it, the moved browser tab and pre-move draft show as tabs. The unload redo after a move rewritten under an open window keeps both, whether the host published first or the rewrite seeded first. * fix(wsl-mover): moved browser tabs and drafts survive the prepared reload after a move "Reload now" backs up unsaved files by dispatching a synthetic unload first. That first unload staged the window's mirror of the host (no browser tab, no draft) and drained the redo mark, so the window, still open, saved its mirror again and the real reload had nothing to redo. The redo now runs at every unload of the stale window and a last time when its document is replaced (did-navigate), and only then is the mark drained. * test(wsl-mover): the undo-path test's store answers the unsaved-file check Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): read workspace records through the selectors Main's new workspace-record read ratchet flags the mover's direct reads; the move dialog, the moved projects section and its store helpers now read through workspace-record-selectors, and the scope rows' catalog names its own fields. Prunes the layout-writer baseline to its actual count. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): the dialog e2e expects the slept panes the start request now names Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): a moved terminal's pane key comes from paneOfPty Main's L80 census allows only paneOfPty and the switch-guarded writers to read a PTY record's pane copies; the WSL move's live-terminal list now reads through it. Co-Authored-By: Claude <noreply@anthropic.com> * style: format the imports the main merge repointed Co-Authored-By: Claude <noreply@anthropic.com> * feat(wsl-mover): carry each moved pane's daemon checkpoint as a terminal-history blob the host restores once Replaces the host-side stored-scrollback seed (#32789) with an installed interrupted session the pane's first start claims. Co-Authored-By: Claude <noreply@anthropic.com> * feat(wsl-mover): the desktop sends each paused pane's checkpoint as its terminal-history blob Drops the snapshot-ref spooling and the restore-hints blob for moved panes: one source (the pane's daemon checkpoint) carries history and folder. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(wsl-mover): register WSL move wiring from its own module Keeps register-core-handlers under the max-lines limit. * fix(wsl-mover): one import from ./ui in the move wiring * refactor(wsl-mover): build the manifest's session before the payload * style(wsl-mover): one import from stable-pane-id after the main merge Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): drop the e2e for the stored-snapshot seed this redesign deletes The moved pane's history now comes from its own checkpoint; wsl-move-restore-lifecycle.spec.ts covers it once, across reloads. Co-Authored-By: Claude <noreply@anthropic.com> * chore(wsl-mover): keep #32668's two layout writers in the baseline until 2/3 deletes them Main's layout-writer ratchet now counts main-process writers; wsl-move-echoed-browser-tabs.ts, the file the hold-and-reload in 2/3 deletes, writes two. Restore redesign 2/3 removes the entry. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): move the project you have open into a host that has its own focus The carried session named this desktop's focus, and a host that already focused another project refused it (orcad_migration_dormant_focus_conflict:activeRepoId), so moving the project you had open reset with "Nothing was moved" (awin verify-7 N1). The desktop's focus names nothing on the host and the desktop rewrites its own focus after the move, so the export now carries none and the host keeps its own. The reset result also says why the move stopped. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): startup removes the slept-history spools of moves the journal dropped A run killed mid-pause is reset at the next startup without its process, so its export documents (25 KB per pane on awin verify-7) stayed under wsl-move-slept-scrollback for good. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): a woken WSL pane keeps its folder unless the probe proves it gone The cold restore checked a WSL pane's folder with stat over \\wsl.localhost, which waits for a stopped distro and fails for reasons other than absence. After a rollback the pane stayed blank about 15 s, then opened at the project root without its folder (awin verify-7, 5e). It now uses the bounded wsl.exe probe every other restore uses, which keeps the folder unless proven missing. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): a moved pane's new shell prompt starts on its own line The carried screen ends on the old shell's prompt with the cursor after it, so the host's new shell printed its prompt beside it (`$ $`, awin verify-7). The exported checkpoint now ends the normal screen with a line break; a TUI's screen and an unfinished escape are left as they are. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): wait on conditions, a fixed clock and stated reasons instead of sleeps Main's flaky-test ratchet flags the mover's tests: - The browser-draft and pane-history e2e slept fixed seconds. They now wait on what they slept for: the host's tabs mirrored, the host terminal's last line, the moved tabs shown, the pane painted. - The journal sweep test read the wall clock; it runs on a fixed fake time. - Each longer per-test timeout says why it needs the time. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(wsl-mover): start the WSL mover from its own startup module The mover's startup (rewrite stores, interrupted-move recovery, retention, retry) lived inline in main-process-ready-foundation.ts. With main, the host reconnect and D1c branches each adding a few lines on top, the file passed the max-lines limit in the combined build. It moves as one unit to wsl-move-startup.ts; the foundation keeps the one call and its ordering. Co-Authored-By: Claude <noreply@anthropic.com> * feat(wsl-mover): hold the window's saves from commit until main reloads it (restore redesign 2/3) The window open across a move hydrated before the desktop rewrite, so every save it made (session, UI, the unload checkpoint behind Reload now) wrote stale state back, and three files re-applied the rewrite after it: echoed browser tabs, the seeded view state's redo and the stale-window reapply. Now main holds that window's saves from the commit request until its reloaded page has loaded, and reloads it itself once the rewrite is on disk. Use on this computer again takes the same path, replacing its per-project release hold. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): only a settled commit reloads the window it held A rewrite pass that ends while a commit request is out (another move's retry) must not reload the window early: the reloaded page could save its view of projects the host is publishing. Co-Authored-By: Claude <noreply@anthropic.com> * chore(wsl-mover): prune the deleted re-apply writers from the layout baseline Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): the move's pause sleeps a workspace again before refusing A workspace whose pane was still restoring, or had just spawned, could miss its final checkpoint on the first Sleep and stay awake; the move then refused with "Orca could not pause the terminals" although a second Sleep stopped it (awin verify-7, 6e). The pause now sleeps the failed workspaces up to twice more, a second apart, and refuses only when they keep failing. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): a move retries a workspace whose pause failed instead of refusing at once When one workspace of a move failed to pause, the move was refused right away with host_terminal_stop_failed, and the retry added for awin verify-7 never ran (awin verify-8, 6e). The window rolls a failed workspace's panes back and reports that rollback as an error. The pause's persist step waited on every workspace and failed on that error, so the whole pause call threw instead of returning the failed workspace. The retry only ran on a returned failure, and the move's first pause had no retry at all. The persist step now fails only on errors from workspaces main reported as slept; main already reports the failed ones. Both of the move's pauses sleep a failed workspace up to twice more before refusing. Each attempt is a fresh pause with its own id, so a re-sleep is not counted as a duplicate. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): e2e for the restart-style restore and undo (restore redesign 3/3, WIP) Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): a moved pane's first subscriber gets its whole replayed history The host replays a moved pane's checkpoint once, but every remote subscribe snapshot carried at most 1000 rows, so the desktop's first view of a 2,000-line pane lost its first half. Only for panes whose session is a moved one, the subscribe snapshot now tries 5000 rows first, then 2000, then the usual ladder, within the same byte budget. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): the restore lifecycle e2e waits for the printer instead of sleeping Main's flaky-test ratchet flags it: it now waits until the host terminal shows the printer's last line before the pane sleeps, and each longer timeout says why. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): undo after reloads and a restart wakes split panes live, each with its history once The undo e2e now follows a real move: main's own Sleep pauses a split tab, the committed move runs its desktop rewrite and retention at startup, the window reloads three times and the app restarts, then "Use on this computer again". The project comes back slept (no live terminal until opened), and opening it wakes both panes from their checkpoints, history once, taking input. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): carried and retained agents resume once across move, reloads and undo The move e2e now carries the pane's idle Codex session to the host (its session file where the distro keeps it): the agent resumes in the moved pane's folder on the first open and no reload resumes it again. The undo e2e gives pane A an idle agent: after the undo it resumes there, once, and pane B starts none. Verify-6 item 5d (an idle agent read as working): the awin stub posts Codex's SessionStart at launch, which Orca reads as a turn start; Codex posts it with the first prompt. The gate test now pins that a resumed agent passes until its next prompt starts a turn. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): read the undo e2e's agent record without a cast Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): parse the undo e2e's agent records instead of reflecting into them Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): the lifecycle e2e shows the moved pane when its agent does not start Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): start the carried agent by its recorded absolute command A login shell may reset PATH (Ubuntu CI did), so the stub is named by the launch config the move carries, as a recorded agent command is. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): say where a resumed moved pane started when it is not its folder Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): print login profiles that move a shell when the resumed pane is outside its folder Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): keep the CI runner's login-profile cd out of the moved pane's folder check Blacksmith's /etc/profile.d/blacksmith.sh ends with cd "$GITHUB_WORKSPACE", so every login shell on CI, restored or new, plain or agent, starts there. Reproduced in Ubuntu 24.04 with the same profile line: a plain restored pane lands in GITHUB_WORKSPACE too, and with it unset the moved pane and its resumed agent start in the pane's folder. Orca spawns the shell in the right folder; the runner's profile moves it. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): judge the resumed pane's folder by where its shell started Blacksmith's /etc/profile.d/blacksmith.sh sources the job env and cds into GITHUB_WORKSPACE, so on CI every login shell, plain or agent, restored or new, moves there after Orca starts it in the pane's folder (reproduced in Ubuntu 24.04 with that profile line; a plain restored pane moves too). The check now accepts the pane's folder as PWD or as the OLDPWD a profile cd left behind. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): undo stops the host's copy of the project before its agents resume here "Use on this computer again" resumed the project's agents here while the host's copies kept running, so one agent session ran on both sides and two writers shared its transcript (awin verify-7, 6c). Undo now sleeps the project's workspaces on the host and waits until none of their terminals runs. A host that does not answer, or whose terminals do not exit, keeps the move and says so; a host the user removed runs nothing, so the project comes back. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): e2e: undo stops the host's running copy before the project comes back A paired host holds the moved project under the move's ids with its terminal still running; after "Use on this computer again" none of the host's terminals for it runs. Fails with the host stop removed (the host's terminal stays at 1). Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): "Use on this computer again" refuses only for the move's own unsaved files An unsaved file in any project refused bringing a moved project back, so p6's draft README blocked undoing p9 (awin verify-8). The window reload no longer stops on an unsaved file, and only the move's own workspaces come back from their pre-move state. The window now tells main which workspaces have unsaved files; main refuses only if the move holds one of them, before it stops anything on the host, and the window names just those files. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): a refused "Use on this computer again" leaves the host's terminals running Undo stopped the host's copies of the move's terminals before it checked whether the move could be released, so an undo refused because a later move builds on it still killed the host's shells and agents; the agents then resumed again (awin verify-9, X1). Undo now runs every refusal first: unsaved files, a running move, a newer move in the chain, and whether the host answers for every workspace. Only then does it stop anything, and a host that cannot answer for one workspace has none of them stopped. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): the move gate finds a pane the window has not reopened since a relaunch A pane the window has not shown since an app relaunch has no spawn-time pane key in main, so the move gate's guest busy check could not find its shell. The gate now falls back to main's own pane record. The Sleep half of the relaunch gap (awin verify-9, X2) moved to main with #33398 and #33425. Co-Authored-By: Claude <noreply@anthropic.com> * chore(wsl-mover): log who spawns each WSL pane and whether its cold restore came with it After an undo reload a restored, inactive terminal tab showed only a prompt: something spawned its session 2 s after the reload, before the window showed it, and its checkpoint never replayed (awin verify-10). No log named the spawner. Each WSL pane spawn or reattach now logs its session, spawn intent, whether it was new, whether a cold restore came with it, and the four caller frames above the daemon client, for verify-11 to capture. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-mover): the undo host-copy e2e says why it needs its longer timeout Main's flaky-test ratchet flags an unexplained per-test timeout. Co-Authored-By: Claude <noreply@anthropic.com> * chore(wsl-mover): record the WSL spawn caller where it enters the daemon router verify-11's `[wsl-spawn-origin]` lines named only the daemon client's own frames (createOrAttachSpawn ← doSpawn ← withDaemonRetry): the async spawn path drops the caller. The caller's frames are now captured synchronously at the router's `spawn` entry and logged with the spawn, so verify-12 can name what starts a restored pane before its window shows it. Co-Authored-By: Claude <noreply@anthropic.com> * chore(wsl-mover): capture the WSL spawn caller where every daemon spawn enters verify-12 still logged "caller":[]. The router only wraps the daemon client when legacy daemons exist; otherwise the bare client is the installed provider, so the router capture never ran. Capture at the client's spawn entry (keyed by the minted id too) and raise the stack limit so the async caller frames survive. Co-Authored-By: Claude <noreply@anthropic.com> * chore(wsl-mover): log the seed and the daemon's screen for WSL cold restores verify-13's second-cycle `$ $` checkpoint shows the daemon's own screen without the fresh-shell blanking (old prompt still in the viewport, scrollbackLines 0), which the macOS daemon harness never reproduces. Log the seed's shape and tail with the spawn, and the daemon's screen 3 s after a WSL cold restore, so the next run shows where it is lost. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-mover): keep an open host pane from restarting its agent during undo "Use on this computer again" slept the host workspace and waited for its terminals to go. A host pane open in the window saw its terminal exit and resumed the agent on the host 1.6 s later, after the stop had already counted zero, so the reloaded window resumed a second copy here (awin verify-14, 6c). Refuse this window's terminal and agent starts on that host from before the stop until the reloaded page has loaded (capped at 2 min), and lift the fence when undo is refused. Co-Authored-By: Claude <noreply@anthropic.com> * chore(wsl-mover): pre-landing cleanup for the dark landing - Strip the verify-only spawn-origin and cold-restore-screen logging and the per-pane process warning. - Read a pane's stored scrollback from its host only for WSL hosts; SSH and paired-server tabs no longer hold for a placeholder or ask their host. - Limit the ambiguous-owner refusal to WSL moves; SSH conversions match as before. - Drop the duplicate split-tab launch check (main fixed it in the projection, #32418). - Reword comments that cited internal verify runs. * fix(terminal): save a woken terminal's first history as a full snapshot A wake that reused the Sleep-time cold-restore cache reopened the history writer without asking for a full checkpoint, so the new process's output was appended to the pre-wake checkpoint, which lacks the fresh-shell padding the seed gave the daemon. The next save that replayed checkpoint + log (quit's final save, or a relaunch's adoption) printed the new shell's prompt on the restored one's row (`$ $`). Mark a full checkpoint for a new process in that path, as the uncached cold-restore path already does. * fix(terminal): keep ConPTY's viewport top through a width change, checked against a captured transcript A captured ConPTY 1.23 resize (wsl.exe) shows it keeps the line at the top of its viewport through every resize and repaints only that viewport. xterm keeps the cursor's rows instead: a wider grid that rejoins wrapped rows pulls rows out of scrollback (ConPTY's repaint then overwrites them), and a narrower one that rewraps pushes rows into it (the repaint then repeats them). The windowsPty row rule covered neither. ConPTY-backed emulators now put the top line back on its row after a resize. * feat(wsl-mover): D3a move orchestrator skeleton (dark) (#33802) * feat(wsl-mover): D3a move orchestrator skeleton, dark behind the internal WSL hosts gate * fix(wsl-mover): exhaustive orchestrator telemetry transition * feat(wsl-mover): D3a T2 trigger, confirmed-running host readiness and T3 guard * fix(wsl-mover): D3a review fixes (stuck moving, user release parks, A1 timer, deferred backoff, chain restore, GUID-only R38, uncounted window failures, backoff docs, restore settle, coalesced wakes) * fix(wsl-mover): restore chain order without mutating * fix(wsl-mover): D3a never touches a WSL host whose Remove is pending (E11 pendingStop) * fix(wsl-mover): D3a survives an unreadable journal, honours a release mid-step, replays wakes after Restore, re-checks pendingStop before readying a host * refactor(wsl-mover): orchestrator ports in their own module * test(wsl-mover): warm the orchestrator wiring import before its first test * fix(wsl-mover): a user Remove of a WSL host declines its move until the user adds it or retries * test(wsl-mover): one checked cast in the host user-action test * fix(wsl-mover): D3a review 3 — removal-pending clears, start re-checks after the window answers, non-blocking replies, replay in finally; split orchestrator modules under max-lines * test(wsl-mover): index check style * test(wsl-mover): core handler test mocks the move orchestrator wiring * fix(wsl-mover): every user Remove of a WSL host declines its move; readying a host re-checks the decline before connect or add * fix(wsl-mover): a failed sidecar save or step error never rejects a fire-and-forget trigger * test(wsl-mover): split orchestrator tests under max-lines with shared fake ports * fix(wsl-mover): report a host Remove before its cleanup, so a failed cleanup keeps the decline * fix(wsl-mover): keep the moved-pane history wire surface off outside a WSL host Register session.readTerminalScrollback, advertise its capability and the terminal-history migration blob, and install that blob only on a WSL move's destination (the WSL host's orcad, or an e2e host standing in for one). Restoring a pane only probes for a moved pane's history there too. The journal's orphaned-write sweep is limited to the WSL move journal, so SSH migration journals behave as on main. Pin the advertised blob kinds again. * fix(wsl-mover): user moves connect the host, automatic ones never boot; gate D3a's selector; tolerate a missing environment list - The dialog's preview and start reach the host through service.connect (may boot, lifts a park); the orchestrator and its startup recovery keep the no-boot path (A1, E9). A host with a Remove pending is never connected either way. - WslMoveDialogHost's active-distro selector does no worktree lookup with the flag off. - hostScrollbackEnvironmentId returns null before the environment list loads; the mount test fixture supplies a WSL host. * fix(wsl-mover): D3a waits out a move still being retained; orchestrator-over-driver tests (#34087) * fix(wsl-mover): D3a waits out a move still being retained instead of failing attempts; orchestrator-over-driver integration tests * fix(wsl-mover): D3a counts an unverifiable in-flight refusal, caps uncounted waits at a day (parks as stuck) and re-checks a done distro waiting on retention * fix(wsl-mover): cap D3a's uncounted waits by checks, not wall clock; any in-flight detail counts * fix(wsl-mover): a user's move leaves an already-ready host connected as is service.connect bumps the liveness generation and re-ensures the server; the orchestrator readies the host before the driver runs, so the driver connects only a host that is not ready. * Revert "fix(terminal): keep ConPTY's viewport top through a width change, checked against a captured transcript" This reverts commitecd8f07396. * test(wsl-mover): the orchestrator's automatic path never reconnects a serving host, and adds a bridge host once (E9) * fix(wsl-mover): keep session.readTerminalScrollback in the RPC manifest; gate its registration Gating the method out of ALL_RPC_METHODS dropped it from the generated params catalog. It is static again, so the catalog types it, and the dispatcher leaves it unregistered on any host that does not serve moved panes' history (method-not-found there, as before). * fix(wsl-mover): undo returns focus from the host copy, so the reload does not reopen it verify-15b 6c: undo stopped the host's copy of p9, but the window's focus still named the host copy (the move pointed it there), so the reloaded window opened that workspace, the host re-created its pane as a visible user-resume, and, since #34054 resumes a restored host pane's agent from the hook store, Codex resumed on the host beside the one restored here. With writes held and before the reload, undo now points focus at the project's copy here, reusing the move's focus writer with the inverse patch. * fix(wsl-mover): undo turns the host's copies of the move's automations off before releasing After "Use on this computer again" the desktop's retained copy of a moved automation runs again, while the host's copy stayed on: both ran against the same files. Before each move is released, undo now turns off, on the host, that move's automations whose copy here is on (one off here keeps the user's choice there). A host that cannot be reached refuses as host-unreachable and one that refuses as failed, releasing nothing, like the terminal stop. * fix(wsl-mover): undo turns host automations off before stopping terminals, and back on if it refuses d3-gate-audit: with the automation step after the terminal stop, a refusal there left the host's terminals and agents killed while undo refused, breaking X1. Per move, undo now turns the automations off first (a step that undoes cleanly), then stops the terminals, then releases. A terminal stop that fails turns back on what was turned off, and a disable that fails partway turns back on what it had turned off before it throws. * fix(wsl-mover): roll back undo's host automation step on any later refusal; review lows - Any refusal after the automations went off (terminal stop, release failure or supersede) turns back on exactly what this attempt turned off. One it cannot is recorded on the move (hostAutomationsLeftOff), shown in the unfinished-move notice and retried by the rewrite retry. - The spawn fence is released in a finally, and the port answers failed instead of throwing on an unreadable environment store. - A host with a pending Remove (E11) is left alone with its own refusal copy; the host-automations refusal tells the user to connect the host. * fix(wsl-mover): undo carries the host's automation states back and leaves each running in one place Owner decision on d3a's item 5: undo reads every one of the move's automations on the host, turns off each host copy that is on, and, with the release, gives the copy here the host's state (the user's latest choice; one deleted there reads as off). An automation turned off on the host after the move no longer comes back on here, and one turned on there no longer keeps running against a project the host no longer owns. A refused undo restores the host's flags and changes nothing here. * fix(wsl-mover): the left-off automation retry re-reads the move after the host round trip d3-gate-audit: the retry wrote back the snapshot it read before awaiting the host, so an undo that released the move meanwhile could be undone, or a phase advance rolled back. It now re-reads the record after the await, writes nothing for a move that is gone, and removes only the ids it turned on, keeping everything else current. * fix(wsl-mover): undo records the user's automation choice before turning any off, and takes turns with the retry d3a re-review and d3-gate-audit: - Undo reads every moved automation on the host, records those states on the move before turning anything off, and applies them here as part of the release step (undone if the release then refuses). An earlier attempt's record wins, so one left off by a failed rollback, or by a crash, keeps its choice (on) here instead of reading as off. - A disable the host drops mid-loop carries what it could not turn back on on its error; those are recorded as left off, shown in the unfinished-move notice and retried. - Undo's per-move steps and the left-off retry take turns, and the retry re-reads the move inside its turn, so a release during a retry neither resurrects the move nor leaves the host copy on. - At startup, an undo a crash interrupted sends what it read as on back on through the retry. * fix(wsl-mover): a left-off retry that finds the move released turns its host copies back off If a move is released while the retry is turning a left-off automation back on, the host copy would run for a project that is local again. After the round trip the retry re-reads the move; when it is gone it turns what it just turned on back off (best effort, logged). * fix(wsl-mover): a restart keeps a left-off automation's choice; undo's rollback never skips the host d3a re-review: - Startup settling keeps the recorded choice (on) for what it sends to the retry, and undo also reads any id on the left-off list as on, so a later undo after a restart and failed retries keeps it on here. - A failed revert of the copies here no longer skips turning the host's copies back on; each revert write is tried on its own. - Turning automations back on stops at a host that stops answering, leaving the rest as left off, instead of waiting on each in turn while undo's turn is held. * fix(wsl-mover): undo always asks the host for the move's terminals, refusing a host that does not answer verify-15c: with the host's orcad stopped, "Use on this computer again" for p7 proceeded, and after a normal undo p6's host shell kept running. Undo's host-copy stop took the host workspaces to stop from this computer's session, which after a move keeps only a moved project's chats: a project with none named no workspace, so undo asked the host nothing (no reachability check, nothing stopped) and released it. The stop now always lists the host's terminals first, adds every workspace of the move's scope that runs one, and refuses as host-unreachable when the host does not answer, answers with an error, or cuts the list short. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ on:
|
||||
- '!**/*.test.ts'
|
||||
- '!**/*.test.tsx'
|
||||
- 'src/main/wsl/wsl-orcad-real-distro-lane.test.ts'
|
||||
- 'src/main/persistence/moving-wsl-projects/wsl-move-real-pause.test.ts'
|
||||
- 'src/main/wsl/wsl-orcad-real-distro-idle-release-lane.test.ts'
|
||||
- '.github/workflows/wsl-orcad-e2e.yml'
|
||||
- 'config/ci/wsl-lifecycle/**'
|
||||
@@ -341,6 +342,18 @@ jobs:
|
||||
run: |
|
||||
node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/wsl/wsl-orcad-real-distro-lane.test.ts --reporter=verbose 2>&1 | Tee-Object (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'survive.log')
|
||||
if ($global:LASTEXITCODE -ne 0) { throw "survive phase failed: $global:LASTEXITCODE" }
|
||||
# The WSL move's pause on real distro shells: Sleep ends them, the daemon proves the exit,
|
||||
# the journal keeps each pane's folder, and a new shell opens there.
|
||||
- name: 'WSL move: pause real distro terminals'
|
||||
if: ${{ !cancelled() && steps.import.outcome == 'success' }}
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
ORCA_WSL_MOVE_REAL_PAUSE_DISTRO: ${{ env.PLAIN_DISTRO }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force $env:ORCA_WSL_ORCAD_LANE_RECEIPTS | Out-Null
|
||||
node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/persistence/moving-wsl-projects/wsl-move-real-pause.test.ts --reporter=verbose 2>&1 | Tee-Object (Join-Path $env:ORCA_WSL_ORCAD_LANE_RECEIPTS 'wsl-move-real-pause.log')
|
||||
if ($global:LASTEXITCODE -ne 0) { throw "WSL move real pause failed: $global:LASTEXITCODE" }
|
||||
wsl.exe --terminate $env:PLAIN_DISTRO
|
||||
# R40: with no terminal left, orcad idle-exits and the holder lets the distro stop on its own.
|
||||
- name: 'Phase 3: idle orcad releases the distro'
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
1 src/main/persistence/loading-store/metadata-lineage-operations.ts
|
||||
1 src/main/persistence/loading-store/mobile-tab-selection-persistence.ts
|
||||
1 src/main/persistence/loading-store/normalize-loaded-profile-state.ts
|
||||
10 src/main/persistence/loading-store/pty-binding-persistence.ts
|
||||
8 src/main/persistence/loading-store/pty-binding-persistence.ts
|
||||
10 src/main/persistence/loading-store/pty-binding-session-update.ts
|
||||
10 src/main/persistence/loading-store/repo-lifecycle-operations.ts
|
||||
10 src/main/persistence/loading-store/session-host-partitions.ts
|
||||
@@ -40,6 +40,7 @@
|
||||
3 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts
|
||||
4 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-subtraction.ts
|
||||
1 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts
|
||||
2 src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.ts
|
||||
1 src/main/persistence/restoring-sessions/folder-workspace-operations.ts
|
||||
1 src/main/persistence/restoring-sessions/invalid-workspace-layout-purge.ts
|
||||
5 src/main/persistence/restoring-sessions/session-owner-fields.ts
|
||||
|
||||
@@ -315,6 +315,23 @@ describe('managed agent hook controls', () => {
|
||||
expect(mocks.removeCodex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// R42: on a WSL host the bridge's panes in the same distro still run on these entries.
|
||||
it('keeps shared entries when told to: off skips, and a disabled agent keeps its entry', async () => {
|
||||
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
|
||||
mocks.statusClaude.mockReturnValue({ agent: 'claude', state: 'installed' })
|
||||
mocks.statusCodex.mockReturnValue({ agent: 'codex', state: 'installed' })
|
||||
|
||||
await applyAgentStatusHooksEnabled(false, null, { keepSharedEntries: true })
|
||||
await applyAgentStatusHooksEnabled(
|
||||
true,
|
||||
{ agentCmdOverrides: {}, disabledTuiAgents: ['claude'] },
|
||||
{ keepSharedEntries: true }
|
||||
)
|
||||
|
||||
expect(mocks.removeClaude).not.toHaveBeenCalled()
|
||||
expect(mocks.removeCodex).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('awaits only the selected asynchronous removers during quit', async () => {
|
||||
let finishRemoval: ((value: ReturnType<typeof status>) => void) | undefined
|
||||
mocks.removeCodexAsync.mockImplementationOnce(
|
||||
|
||||
@@ -40,6 +40,11 @@ type InstallOptions = {
|
||||
agents?: readonly AgentHookTarget[]
|
||||
}
|
||||
|
||||
type ApplyOptions = InstallOptions & {
|
||||
/** Off means skip, never remove: another Orca's panes run on these same user-global entries. */
|
||||
keepSharedEntries?: boolean
|
||||
}
|
||||
|
||||
type RemoveOptions = {
|
||||
agents?: readonly AgentHookTarget[]
|
||||
}
|
||||
@@ -244,10 +249,12 @@ export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
|
||||
export async function applyAgentStatusHooksEnabled(
|
||||
enabled: boolean,
|
||||
settings: ManagedHookSettings = null,
|
||||
options: InstallOptions = {}
|
||||
options: ApplyOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
if (!enabled) {
|
||||
return await removeManagedAgentHooks()
|
||||
return options.keepSharedEntries
|
||||
? getManagedAgentHookStatuses()
|
||||
: await removeManagedAgentHooks()
|
||||
}
|
||||
const disabled = normalizeDisabledTuiAgents(settings?.disabledTuiAgents).filter(
|
||||
isManagedAgentHookTarget
|
||||
@@ -256,7 +263,7 @@ export async function applyAgentStatusHooksEnabled(
|
||||
const disabledToRemove = options.shouldContinue
|
||||
? disabled.filter((agent) => !options.shouldContinue?.(agent))
|
||||
: disabled
|
||||
if (disabledToRemove.length === 0) {
|
||||
if (disabledToRemove.length === 0 || options.keepSharedEntries) {
|
||||
return installed
|
||||
}
|
||||
const removed = new Map(
|
||||
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
loadWslMoveFence,
|
||||
resetWslMoveFenceForTests
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { runWslMoveDesktopRewritePass } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite'
|
||||
import { resetWslMoveDesktopRewriteStoresForTests } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores'
|
||||
import { registerWslMoveMainRewriteStores } from '../persistence/moving-wsl-projects/wsl-move-main-rewrite-stores'
|
||||
import { registerWslMoveOrchestrationHistoryStore } from '../persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite'
|
||||
import { orchestrationDbFile } from '../runtime/orchestration/db/orchestration-db-file'
|
||||
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate'
|
||||
import { OrchestrationDb } from '../runtime/orchestration/db'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { AutomationService } from './service'
|
||||
import { WSL_MOVE_IN_PROGRESS, WSL_MOVE_OWNED_BY_HOST } from './dispatch-refusal'
|
||||
import type * as PrecheckRunner from './precheck-runner'
|
||||
@@ -146,6 +154,45 @@ describe('scheduled automations under a WSL move', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the local copy unchanged through the desktop rewrite, so it neither runs nor loses its next run', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
const store = await createStore()
|
||||
const automation = seedAutomation(store)
|
||||
seedWslMoveCutover(
|
||||
fenceDir,
|
||||
cutover('move-1', 'destination-committed', {}, { repoIds: ['r1'] })
|
||||
)
|
||||
const databasePath = orchestrationDbFile(fenceDir)
|
||||
// A dispatch in the moved repo, so the orchestration store has real work to do.
|
||||
new OrchestrationDb(databasePath).close()
|
||||
const seed = new Database(databasePath)
|
||||
seed
|
||||
.prepare(`INSERT INTO worker_dispatches (dispatch_id, state, worktree_id) VALUES (?, ?, ?)`)
|
||||
.run('d-1', 'succeeded', 'r1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\r1')
|
||||
seed.close()
|
||||
registerWslMoveMainRewriteStores(store)
|
||||
registerWslMoveOrchestrationHistoryStore(databasePath)
|
||||
try {
|
||||
await runWslMoveDesktopRewritePass({ userDataPath: fenceDir })
|
||||
const read = new Database(databasePath)
|
||||
expect(read.prepare(`SELECT worktree_id FROM worker_dispatches`).get()).toEqual({
|
||||
worktree_id: 'r1::/home/dev/r1'
|
||||
})
|
||||
read.close()
|
||||
expect(store.listAutomations()).toEqual([automation])
|
||||
const service = attachedService(store)
|
||||
|
||||
await evaluateAt(store, service, automation.id, '2026-05-13T09:01:00')
|
||||
|
||||
expect(store.listAutomationRuns(automation.id)).toEqual([])
|
||||
expect(store.listAutomations()[0]?.nextRunAt).toBe(new Date('2026-05-14T09:00:00').getTime())
|
||||
} finally {
|
||||
resetWslMoveDesktopRewriteStoresForTests()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips, not fails, a headless run whose move starts during the precheck', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00'))
|
||||
const store = await createStore()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** A cookie as CDP's Network domain reports it: unlike `cookies.get()`, with its partition key. */
|
||||
export type CdpCookiePartitionKey = {
|
||||
topLevelSite?: string
|
||||
hasCrossSiteAncestor?: boolean
|
||||
}
|
||||
|
||||
export type CdpCookie = {
|
||||
name: string
|
||||
value: string
|
||||
domain?: string
|
||||
path?: string
|
||||
secure?: boolean
|
||||
httpOnly?: boolean
|
||||
session?: boolean
|
||||
expires?: number
|
||||
sameSite?: string
|
||||
partitionKey?: CdpCookiePartitionKey | null
|
||||
partitionKeyOpaque?: boolean
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CdpCookie } from './browser-cdp-cookie'
|
||||
|
||||
type FakeSession = { partition: string; cookies: { flushStore: () => Promise<void> } }
|
||||
|
||||
const electron = vi.hoisted(() => {
|
||||
const jars = new Map<string, Record<string, unknown>[]>()
|
||||
const sessions = new Map<string, FakeSession>()
|
||||
const flushed: string[] = []
|
||||
const windows: { shown: boolean; destroyed: boolean }[] = []
|
||||
return { jars, sessions, flushed, windows }
|
||||
})
|
||||
|
||||
function jar(partition: string): Record<string, unknown>[] {
|
||||
let cookies = electron.jars.get(partition)
|
||||
if (!cookies) {
|
||||
cookies = []
|
||||
electron.jars.set(partition, cookies)
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
vi.mock('electron', () => {
|
||||
class BrowserWindow {
|
||||
private readonly state = { shown: false, destroyed: false }
|
||||
readonly webContents: unknown
|
||||
constructor(options: { show?: boolean; webPreferences: { session: FakeSession } }) {
|
||||
this.state.shown = options.show !== false
|
||||
electron.windows.push(this.state)
|
||||
const partition = options.webPreferences.session.partition
|
||||
this.webContents = {
|
||||
isDestroyed: () => false,
|
||||
debugger: {
|
||||
sendCommand: async (method: string, params: Record<string, unknown> = {}) => {
|
||||
if (method === 'Network.getAllCookies') {
|
||||
return { cookies: jar(partition).map((cookie) => ({ ...cookie })) }
|
||||
}
|
||||
if (method === 'Network.setCookie') {
|
||||
if (String(params.value).includes('refused')) {
|
||||
return { success: false }
|
||||
}
|
||||
const url = new URL(String(params.url))
|
||||
jar(partition).push({
|
||||
name: params.name,
|
||||
value: params.value,
|
||||
domain: params.domain ?? url.hostname,
|
||||
path: params.path ?? '/',
|
||||
secure: params.secure,
|
||||
httpOnly: params.httpOnly,
|
||||
sameSite: params.sameSite,
|
||||
expires: params.expires ?? -1,
|
||||
session: params.expires === undefined,
|
||||
...(params.partitionKey ? { partitionKey: params.partitionKey } : {})
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
throw new Error(`unexpected CDP command ${method}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async loadURL(): Promise<void> {}
|
||||
show(): void {
|
||||
this.state.shown = true
|
||||
}
|
||||
destroy(): void {
|
||||
this.state.destroyed = true
|
||||
}
|
||||
}
|
||||
return {
|
||||
BrowserWindow,
|
||||
webContents: { getAllWebContents: () => [] },
|
||||
session: {
|
||||
fromPartition: (partition: string): FakeSession => {
|
||||
let existing = electron.sessions.get(partition)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
partition,
|
||||
cookies: {
|
||||
flushStore: async () => {
|
||||
electron.flushed.push(partition)
|
||||
}
|
||||
}
|
||||
}
|
||||
electron.sessions.set(partition, existing)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('./electron-debugger-lease', () => ({
|
||||
acquireElectronDebugger: () => ({ release: () => {} })
|
||||
}))
|
||||
|
||||
import {
|
||||
copyBrowserPartitionCookies,
|
||||
flushBrowserPartitionCookies,
|
||||
planBrowserPartitionCookieCopy
|
||||
} from './browser-partition-cookie-copy'
|
||||
|
||||
const SOURCE = 'persist:orca-browser-local'
|
||||
const DESTINATION = `persist:orca-browser-v1-${'a'.repeat(64)}`
|
||||
|
||||
function cookie(overrides: Partial<CdpCookie> & { name: string }): CdpCookie {
|
||||
return {
|
||||
value: 'v',
|
||||
domain: '.example.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
session: false,
|
||||
expires: 2_000_000_000,
|
||||
sameSite: 'Lax',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
electron.jars.clear()
|
||||
electron.sessions.clear()
|
||||
electron.flushed.length = 0
|
||||
electron.windows.length = 0
|
||||
})
|
||||
|
||||
describe('planBrowserPartitionCookieCopy', () => {
|
||||
it('copies httpOnly, partitioned and session-only cookies faithfully', () => {
|
||||
const plan = planBrowserPartitionCookieCopy(
|
||||
[
|
||||
cookie({ name: 'sid', httpOnly: true }),
|
||||
cookie({
|
||||
name: 'chips',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: false }
|
||||
}),
|
||||
cookie({ name: 'tab', session: true, expires: -1 }),
|
||||
cookie({ name: '__Host-id', domain: 'app.example.com' })
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
expect(plan.counts.rejected).toBe(0)
|
||||
expect(plan.writes.map((write) => write.name)).toEqual(['sid', 'chips', 'tab', '__Host-id'])
|
||||
expect(plan.writes[0]).toMatchObject({ httpOnly: true, hostOnly: false })
|
||||
expect(plan.writes[1]?.partitionKey).toEqual({
|
||||
topLevelSite: 'https://top.example',
|
||||
hasCrossSiteAncestor: false
|
||||
})
|
||||
expect(plan.writes[2]?.expirationDate).toBeUndefined()
|
||||
expect(plan.writes[3]).toMatchObject({ hostOnly: true, url: 'https://app.example.com/' })
|
||||
})
|
||||
|
||||
it('skips cookies the target already holds, source-bound sites, and opaque partitions', () => {
|
||||
const plan = planBrowserPartitionCookieCopy(
|
||||
[
|
||||
cookie({ name: 'sid', value: 'desktop' }),
|
||||
cookie({ name: 'SID', domain: '.google.com' }),
|
||||
cookie({ name: 'embed', partitionKeyOpaque: true }),
|
||||
cookie({ name: 'bad', domain: undefined }),
|
||||
cookie({
|
||||
name: 'chips',
|
||||
partitionKey: { topLevelSite: 'not a site', hasCrossSiteAncestor: false }
|
||||
})
|
||||
],
|
||||
[cookie({ name: 'sid', value: 'host' })]
|
||||
)
|
||||
|
||||
expect(plan.writes).toEqual([])
|
||||
expect(plan.counts).toMatchObject({
|
||||
skippedPresent: 1,
|
||||
skippedSourceBound: 1,
|
||||
skippedOpaquePartition: 1,
|
||||
rejected: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a partitioned and an unpartitioned cookie of the same name apart', () => {
|
||||
const plan = planBrowserPartitionCookieCopy(
|
||||
[
|
||||
cookie({
|
||||
name: 'sid',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: false }
|
||||
})
|
||||
],
|
||||
[cookie({ name: 'sid' })]
|
||||
)
|
||||
expect(plan.writes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyBrowserPartitionCookies', () => {
|
||||
it('merges into the destination, never writes the source, and never shows a window', async () => {
|
||||
jar(SOURCE).push(
|
||||
cookie({ name: 'sid', httpOnly: true }),
|
||||
cookie({ name: 'tab', session: true, expires: -1 }),
|
||||
cookie({
|
||||
name: 'chips',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
}),
|
||||
cookie({ name: 'kept', value: 'desktop' }),
|
||||
cookie({ name: 'odd', value: 'refused' })
|
||||
)
|
||||
jar(DESTINATION).push(cookie({ name: 'kept', value: 'host' }))
|
||||
const sourceBefore = structuredClone(jar(SOURCE))
|
||||
|
||||
const counts = await copyBrowserPartitionCookies(SOURCE, DESTINATION)
|
||||
|
||||
expect(counts).toEqual({
|
||||
copied: 3,
|
||||
copiedHttpOnly: 1,
|
||||
copiedPartitioned: 1,
|
||||
copiedSessionOnly: 1,
|
||||
skippedPresent: 1,
|
||||
skippedSourceBound: 0,
|
||||
skippedOpaquePartition: 0,
|
||||
rejected: 1
|
||||
})
|
||||
expect(jar(SOURCE)).toEqual(sourceBefore)
|
||||
expect(jar(DESTINATION).find((entry) => entry.name === 'kept')?.value).toBe('host')
|
||||
expect(jar(DESTINATION).find((entry) => entry.name === 'chips')?.partitionKey).toEqual({
|
||||
topLevelSite: 'https://top.example',
|
||||
hasCrossSiteAncestor: true
|
||||
})
|
||||
expect(electron.windows.every((window) => !window.shown && window.destroyed)).toBe(true)
|
||||
})
|
||||
|
||||
it('writes nothing twice when re-run after a crash', async () => {
|
||||
jar(SOURCE).push(cookie({ name: 'sid' }), cookie({ name: 'pref' }))
|
||||
await copyBrowserPartitionCookies(SOURCE, DESTINATION)
|
||||
|
||||
const again = await copyBrowserPartitionCookies(SOURCE, DESTINATION)
|
||||
|
||||
expect(again.copied).toBe(0)
|
||||
expect(again.skippedPresent).toBe(2)
|
||||
expect(jar(DESTINATION)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refuses to copy a partition into itself', async () => {
|
||||
await expect(copyBrowserPartitionCookies(SOURCE, SOURCE)).rejects.toThrow(
|
||||
'browser_partition_cookie_copy_same_partition'
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes the destination jar to disk', async () => {
|
||||
await flushBrowserPartitionCookies(DESTINATION)
|
||||
expect(electron.flushed).toEqual([DESTINATION])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copies one browser partition's cookies into another, without overwriting what the target holds
|
||||
* and without touching the source. Reads and writes go through CDP: Electron's cookies API drops
|
||||
* the partition key, which would turn a partitioned cookie into a cross-site one.
|
||||
*/
|
||||
import { session } from 'electron'
|
||||
import {
|
||||
emptyWslMoveBrowserCookieCopyCounts,
|
||||
type WslMoveBrowserCookieCopyCounts
|
||||
} from '../../shared/wsl-move/wsl-move-browser-cookie-copy'
|
||||
import type { CdpCookie } from './browser-cdp-cookie'
|
||||
import { identityFromCdpCookie, openCookieClearStore } from './cookies/browser-cookie-clear-store'
|
||||
import {
|
||||
withCookieMutationLock,
|
||||
type CookieClearIdentity
|
||||
} from './cookies/browser-cookie-import-clear'
|
||||
import {
|
||||
isNonTransplantableCookieDomain,
|
||||
normalizeCookieDomain
|
||||
} from './cookies/browser-cookie-import-policy'
|
||||
|
||||
export type BrowserPartitionCookieCopyCounts = WslMoveBrowserCookieCopyCounts
|
||||
|
||||
export type BrowserPartitionCookieCopyPlan = {
|
||||
writes: CookieClearIdentity[]
|
||||
/** Skip and reject counts; the copy adds the write results. */
|
||||
counts: BrowserPartitionCookieCopyCounts
|
||||
}
|
||||
|
||||
/** Pure: what to write from `source` into a jar that already holds `destination`. */
|
||||
export function planBrowserPartitionCookieCopy(
|
||||
source: readonly CdpCookie[],
|
||||
destination: readonly CdpCookie[]
|
||||
): BrowserPartitionCookieCopyPlan {
|
||||
const counts = emptyWslMoveBrowserCookieCopyCounts()
|
||||
const present = new Set<string>()
|
||||
for (const cookie of destination) {
|
||||
const key = cookieKey(cookie)
|
||||
if (key) {
|
||||
present.add(key)
|
||||
}
|
||||
}
|
||||
const writes: CookieClearIdentity[] = []
|
||||
for (const cookie of source) {
|
||||
const key = cookieKey(cookie)
|
||||
const host = typeof cookie.domain === 'string' ? normalizeCookieDomain(cookie.domain) : null
|
||||
if (!key || !host || typeof cookie.value !== 'string') {
|
||||
counts.rejected += 1
|
||||
continue
|
||||
}
|
||||
if (isNonTransplantableCookieDomain(host)) {
|
||||
counts.skippedSourceBound += 1
|
||||
continue
|
||||
}
|
||||
if (cookie.partitionKeyOpaque === true) {
|
||||
counts.skippedOpaquePartition += 1
|
||||
continue
|
||||
}
|
||||
if (present.has(key)) {
|
||||
counts.skippedPresent += 1
|
||||
continue
|
||||
}
|
||||
let identity: CookieClearIdentity
|
||||
try {
|
||||
identity = identityFromCdpCookie(cookieUrl(cookie, host), cookie)
|
||||
} catch {
|
||||
counts.rejected += 1
|
||||
continue
|
||||
}
|
||||
present.add(key)
|
||||
writes.push(identity)
|
||||
}
|
||||
return { writes, counts }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges `sourcePartition`'s cookies into `destinationPartition`. Re-running it after a crash
|
||||
* writes nothing twice: every cookie the first run wrote is already present.
|
||||
*/
|
||||
export async function copyBrowserPartitionCookies(
|
||||
sourcePartition: string,
|
||||
destinationPartition: string
|
||||
): Promise<BrowserPartitionCookieCopyCounts> {
|
||||
if (sourcePartition === destinationPartition) {
|
||||
throw new Error('browser_partition_cookie_copy_same_partition')
|
||||
}
|
||||
const destinationSession = session.fromPartition(destinationPartition)
|
||||
const source = openCookieClearStore(session.fromPartition(sourcePartition))
|
||||
const destination = openCookieClearStore(destinationSession)
|
||||
try {
|
||||
const sourceCookies = await source.readAllCdpCookies()
|
||||
return await withCookieMutationLock(destinationSession, async () => {
|
||||
const plan = planBrowserPartitionCookieCopy(
|
||||
sourceCookies,
|
||||
await destination.readAllCdpCookies()
|
||||
)
|
||||
for (const identity of plan.writes) {
|
||||
try {
|
||||
await destination.writeCookieIdentity(identity)
|
||||
} catch {
|
||||
plan.counts.rejected += 1
|
||||
continue
|
||||
}
|
||||
plan.counts.copied += 1
|
||||
plan.counts.copiedHttpOnly += identity.httpOnly === true ? 1 : 0
|
||||
plan.counts.copiedPartitioned += identity.partitionKey ? 1 : 0
|
||||
plan.counts.copiedSessionOnly += identity.expirationDate === undefined ? 1 : 0
|
||||
}
|
||||
return plan.counts
|
||||
})
|
||||
} finally {
|
||||
source.dispose()
|
||||
destination.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves once the partition's cookie writes are on disk, not just in memory. */
|
||||
export function flushBrowserPartitionCookies(partition: string): Promise<void> {
|
||||
return session.fromPartition(partition).cookies.flushStore()
|
||||
}
|
||||
|
||||
function cookieKey(cookie: CdpCookie): string | null {
|
||||
if (typeof cookie.name !== 'string' || typeof cookie.domain !== 'string' || !cookie.domain) {
|
||||
return null
|
||||
}
|
||||
const partitionKey = cookie.partitionKey
|
||||
? [cookie.partitionKey.topLevelSite ?? null, cookie.partitionKey.hasCrossSiteAncestor ?? null]
|
||||
: null
|
||||
return JSON.stringify([
|
||||
cookie.name,
|
||||
cookie.domain.toLowerCase(),
|
||||
cookie.path || '/',
|
||||
partitionKey,
|
||||
cookie.partitionKeyOpaque === true
|
||||
])
|
||||
}
|
||||
|
||||
function cookieUrl(cookie: CdpCookie, host: string): string {
|
||||
const url = new URL(`${cookie.secure ? 'https' : 'http'}://${host}/`)
|
||||
url.pathname = cookie.path?.startsWith('/') ? cookie.path : '/'
|
||||
return url.toString()
|
||||
}
|
||||
@@ -42,3 +42,8 @@ export function initializeBrowserSessionsForApp(
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
initialized = true
|
||||
}
|
||||
|
||||
/** Whether a partition may be opened: before this, opening one would skip a pending cookie import. */
|
||||
export function browserSessionsInitialized(): boolean {
|
||||
return initialized
|
||||
}
|
||||
|
||||
@@ -8,29 +8,12 @@ import type {
|
||||
CookieClearIdentity,
|
||||
CookieClearPartitionKey,
|
||||
CookieClearStore,
|
||||
CookieImportWriteStore
|
||||
CookieImportWriteStore,
|
||||
CookieJarCdpReadStore
|
||||
} from './browser-cookie-import-clear'
|
||||
import { restoreEveryCookieIdentity } from './browser-cookie-identity-restore'
|
||||
import { normalizeCookiePartitionSite } from './sources/browser-cookie-source-partition'
|
||||
|
||||
type CdpCookiePartitionKey = {
|
||||
topLevelSite?: string
|
||||
hasCrossSiteAncestor?: boolean
|
||||
}
|
||||
|
||||
type CdpCookie = {
|
||||
name: string
|
||||
value: string
|
||||
domain?: string
|
||||
path?: string
|
||||
secure?: boolean
|
||||
httpOnly?: boolean
|
||||
session?: boolean
|
||||
expires?: number
|
||||
sameSite?: string
|
||||
partitionKey?: CdpCookiePartitionKey | null
|
||||
partitionKeyOpaque?: boolean
|
||||
}
|
||||
import type { CdpCookie } from '../browser-cdp-cookie'
|
||||
|
||||
function cdpSameSite(sameSite: Cookie['sameSite']): 'Strict' | 'Lax' | 'None' | undefined {
|
||||
if (sameSite === 'strict') {
|
||||
@@ -96,7 +79,7 @@ function indexCdpCookies(cookies: readonly CdpCookie[]): Map<string, CdpCookie[]
|
||||
return index
|
||||
}
|
||||
|
||||
function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearIdentity {
|
||||
export function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearIdentity {
|
||||
const partitionKey = partitionKeyFromCdp(cdpCookie)
|
||||
return {
|
||||
url,
|
||||
@@ -209,7 +192,7 @@ async function writeIdentityWithCdp(
|
||||
|
||||
export function openCookieClearStore(
|
||||
targetSession: Session
|
||||
): CookieClearStore & CookieImportWriteStore & { dispose: () => void } {
|
||||
): CookieClearStore & CookieImportWriteStore & CookieJarCdpReadStore & { dispose: () => void } {
|
||||
const { attach, dispose } = openCookieDebuggerSession(targetSession)
|
||||
return {
|
||||
get: (filter) => targetSession.cookies.get(filter),
|
||||
@@ -224,6 +207,8 @@ export function openCookieClearStore(
|
||||
},
|
||||
writeCookieIdentity: async (identity) =>
|
||||
writeIdentityWithCdp((await attach()).debugger, identity, 'import'),
|
||||
readAllCdpCookies: async () =>
|
||||
cdpCookiesFromCommand(await (await attach()).debugger.sendCommand('Network.getAllCookies')),
|
||||
dispose
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Cookie, Cookies } from 'electron'
|
||||
import { mapSettledWithConcurrency } from '../../../shared/map-with-concurrency'
|
||||
import type { CdpCookie } from '../browser-cdp-cookie'
|
||||
import type { ImportedDomainScope } from './browser-cookie-import-policy'
|
||||
import {
|
||||
cookieRemovalUrl,
|
||||
@@ -42,6 +43,11 @@ export type CookieImportWriteStore = Pick<Cookies, 'get' | 'remove'> & {
|
||||
writeCookieIdentity(identity: CookieClearIdentity): Promise<void>
|
||||
}
|
||||
|
||||
// Why: cookies.get() leaves out partitionKey, so a whole-jar copy reads through CDP too.
|
||||
export type CookieJarCdpReadStore = {
|
||||
readAllCdpCookies(): Promise<CdpCookie[]>
|
||||
}
|
||||
|
||||
// Exclude cookies.set and bulk clearData: neither preserves this per-cookie rollback contract.
|
||||
export type CookieClearSession = {
|
||||
cookies: Pick<Cookies, 'get' | 'remove'>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* R45: which in-app browser jar a WSL host's pages get, pinned by what can change about the host.
|
||||
* The jar follows the environment (named by the distro) and orcad's key; the distro's registry id
|
||||
* is deliberately not part of it (#29610).
|
||||
*/
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import { defaultWslOrcadEnvironmentId } from '../wsl/wsl-orcad-deploy'
|
||||
|
||||
const bindings = vi.hoisted(() => ({ filePath: '' }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => tmpdir() },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() }
|
||||
}))
|
||||
vi.mock('./browser-route-partition-binding-runtime', async () => {
|
||||
const { BrowserRoutePartitionBindingStore } =
|
||||
await import('./browser-route-partition-binding-store')
|
||||
return {
|
||||
currentBrowserRoutePartitionBindingStore: () =>
|
||||
new BrowserRoutePartitionBindingStore({ filePath: bindings.filePath })
|
||||
}
|
||||
})
|
||||
vi.mock('./browser-route-partition-storage-dependencies', () => ({
|
||||
releaseEvictedBrowserRoutePartitionStorage: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
import {
|
||||
bindWslHostBrowserRoutePartition,
|
||||
wslHostBrowserRouteStorage
|
||||
} from './wsl-host-browser-route-partition'
|
||||
|
||||
const ORCA_PROFILE = 'profile-a'
|
||||
|
||||
function wslHost(
|
||||
distro: string,
|
||||
hostKey: string,
|
||||
pairing = { revision: 1, device: 'device-a' }
|
||||
): KnownRuntimeEnvironment {
|
||||
return {
|
||||
id: defaultWslOrcadEnvironmentId(distro),
|
||||
name: distro,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
pairingRevision: pairing.revision,
|
||||
pairedDeviceId: pairing.device,
|
||||
orcadDeployment: { kind: 'wsl', distro, localPort: 9999, remotePort: 7777 },
|
||||
preferredEndpointId: 'endpoint-a',
|
||||
endpoints: [
|
||||
{
|
||||
id: 'endpoint-a',
|
||||
kind: 'websocket',
|
||||
label: distro,
|
||||
endpoint: 'ws://127.0.0.1:9999',
|
||||
deviceToken: `token-${pairing.device}`,
|
||||
publicKeyB64: hostKey
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function jar(environment: KnownRuntimeEnvironment): string {
|
||||
const result = wslHostBrowserRouteStorage(ORCA_PROFILE, environment)
|
||||
if (result.kind !== 'stable') {
|
||||
throw new Error(`expected a stable partition, got ${result.reason}`)
|
||||
}
|
||||
return bindWslHostBrowserRoutePartition(result.storage, 'default')
|
||||
}
|
||||
|
||||
let directory: string
|
||||
|
||||
beforeEach(() => {
|
||||
directory = realpathSync(mkdtempSync(join(realpathSync(tmpdir()), 'wsl-host-jar-')))
|
||||
bindings.filePath = join(directory, 'bindings.json')
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('WSL host browser jar identity', () => {
|
||||
it('starts a new jar when the distro runs a newly deployed orcad (a new key)', () => {
|
||||
const before = jar(wslHost('Ubuntu', 'orcad-key-a'))
|
||||
|
||||
expect(jar(wslHost('Ubuntu', 'orcad-key-b'))).not.toBe(before)
|
||||
// The old jar is still the old key's; nothing of it moved to the new one.
|
||||
expect(jar(wslHost('Ubuntu', 'orcad-key-a'))).toBe(before)
|
||||
})
|
||||
|
||||
it('starts a new jar for a renamed distro, even one that kept its orcad key', () => {
|
||||
const before = jar(wslHost('Ubuntu', 'orcad-key-a'))
|
||||
|
||||
expect(jar(wslHost('Ubuntu-work', 'orcad-key-a'))).not.toBe(before)
|
||||
})
|
||||
|
||||
// A golden image with a baked-in orcad key, imported twice on one desktop.
|
||||
it('keeps two distros that carry the same orcad key in separate jars', () => {
|
||||
expect(jar(wslHost('Ubuntu', 'baked-key'))).not.toBe(jar(wslHost('Ubuntu-2', 'baked-key')))
|
||||
})
|
||||
|
||||
// Accepted (R45, Low): the same name and key is the same host to this desktop.
|
||||
it('keeps the jar for a distro re-imported under its old name with its old key', () => {
|
||||
const before = jar(wslHost('Ubuntu', 'orcad-key-a'))
|
||||
|
||||
expect(jar(wslHost('Ubuntu', 'orcad-key-a', { revision: 7, device: 'device-b' }))).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
KnownRuntimeEnvironment,
|
||||
OrcadDeploymentLink
|
||||
} from '../../shared/runtime-environments'
|
||||
|
||||
const bindings = vi.hoisted(() => ({ filePath: '' }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => tmpdir() },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() }
|
||||
}))
|
||||
vi.mock('./paired-runtime-browser-client-host-composition', () => ({
|
||||
PairedRuntimeBrowserClientHostComposition: class {
|
||||
start(): Promise<unknown> {
|
||||
return Promise.resolve({ authority: 'lease-a' })
|
||||
}
|
||||
replaceAuthority(): Promise<unknown> {
|
||||
return Promise.resolve({ authority: 'lease-a' })
|
||||
}
|
||||
close(): Promise<boolean> {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
whenClosed(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./browser-route-partition-binding-runtime', async () => {
|
||||
const { BrowserRoutePartitionBindingStore } =
|
||||
await import('./browser-route-partition-binding-store')
|
||||
return {
|
||||
currentBrowserRoutePartitionBindingStore: () =>
|
||||
new BrowserRoutePartitionBindingStore({ filePath: bindings.filePath })
|
||||
}
|
||||
})
|
||||
vi.mock('./browser-route-partition-storage-dependencies', () => ({
|
||||
releaseEvictedBrowserRoutePartitionStorage: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
import { BrowserRoutePartitionBindingStore } from './browser-route-partition-binding-store'
|
||||
import { resolveBrowserRoutePartitionBinding } from './browser-route-partition-migration'
|
||||
import {
|
||||
closePairedRuntimeBrowserClientHostEnvironment,
|
||||
configurePairedRuntimeBrowserClientHostsForOrcaProfile,
|
||||
getPairedRuntimeBrowserClientRouteIdentity,
|
||||
startPairedRuntimeBrowserClientHost,
|
||||
type ClientHostRouteIdentity
|
||||
} from './paired-runtime-browser-client-host-runtime'
|
||||
import {
|
||||
bindWslHostBrowserRoutePartition,
|
||||
wslHostBrowserRouteStorage
|
||||
} from './wsl-host-browser-route-partition'
|
||||
|
||||
const ORCA_PROFILE = 'profile-a'
|
||||
const WSL_DEPLOYMENT: OrcadDeploymentLink = {
|
||||
kind: 'wsl',
|
||||
distro: 'Ubuntu',
|
||||
localPort: 9999,
|
||||
remotePort: 7777
|
||||
}
|
||||
const SSH_DEPLOYMENT: OrcadDeploymentLink = {
|
||||
sshTargetId: 'target-a',
|
||||
sshTargetGeneration: 1,
|
||||
localPort: 9999,
|
||||
remotePort: 7777
|
||||
}
|
||||
|
||||
function environment(
|
||||
pairing: { revision: number; device: string },
|
||||
orcadDeployment: OrcadDeploymentLink | null = WSL_DEPLOYMENT,
|
||||
id = 'wsl:Ubuntu'
|
||||
): KnownRuntimeEnvironment {
|
||||
return {
|
||||
id,
|
||||
name: 'Ubuntu',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
pairingRevision: pairing.revision,
|
||||
pairedDeviceId: pairing.device,
|
||||
...(orcadDeployment ? { orcadDeployment } : {}),
|
||||
preferredEndpointId: 'endpoint-a',
|
||||
endpoints: [
|
||||
{
|
||||
id: 'endpoint-a',
|
||||
kind: 'websocket',
|
||||
label: 'Ubuntu',
|
||||
endpoint: 'ws://127.0.0.1:9999',
|
||||
deviceToken: `token-${pairing.device}`,
|
||||
publicKeyB64: 'host-key-a'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function stableStorage(env: KnownRuntimeEnvironment) {
|
||||
const result = wslHostBrowserRouteStorage(ORCA_PROFILE, env)
|
||||
if (result.kind !== 'stable') {
|
||||
throw new Error(`expected a stable partition, got ${result.reason}`)
|
||||
}
|
||||
return result.storage
|
||||
}
|
||||
|
||||
async function liveRouteIdentity(
|
||||
env: KnownRuntimeEnvironment,
|
||||
authorityRuntimeId: string
|
||||
): Promise<ClientHostRouteIdentity> {
|
||||
await startPairedRuntimeBrowserClientHost({ environment: env, authorityRuntimeId })
|
||||
const identity = getPairedRuntimeBrowserClientRouteIdentity(env.id)
|
||||
await closePairedRuntimeBrowserClientHostEnvironment(env.id)
|
||||
if (!identity) {
|
||||
throw new Error('missing route identity')
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
/** The partition the host's in-app page lands in, resolved the way page creation does it. */
|
||||
async function pagePartition(
|
||||
env: KnownRuntimeEnvironment,
|
||||
authorityRuntimeId: string,
|
||||
browserProfileId = 'default'
|
||||
): Promise<string> {
|
||||
const identity = await liveRouteIdentity(env, authorityRuntimeId)
|
||||
const page = (authorityConnectionIdentity: string, executionHostIdentity: string) => ({
|
||||
orcaProfileId: identity.orcaProfileId,
|
||||
browserProfileId,
|
||||
authorityConnectionIdentity,
|
||||
executionHostIdentity
|
||||
})
|
||||
const store = new BrowserRoutePartitionBindingStore({ filePath: bindings.filePath })
|
||||
const derived = resolveBrowserRoutePartitionBinding({
|
||||
bindings: store,
|
||||
identity: page(identity.authorityConnectionIdentity, identity.executionHostIdentity),
|
||||
compatibleIdentity: page(
|
||||
identity.pairingAuthorityConnectionIdentity,
|
||||
identity.executionHostIdentity
|
||||
),
|
||||
legacyIdentity: page(
|
||||
identity.legacyAuthorityConnectionIdentity,
|
||||
identity.legacyExecutionHostIdentity
|
||||
),
|
||||
storageScope: identity.storageScope
|
||||
})
|
||||
const persisted = store.get(derived.partition)
|
||||
if (persisted === null) {
|
||||
store.set(
|
||||
derived.partition,
|
||||
derived.bindingFingerprint,
|
||||
identity.storageScope,
|
||||
derived.aliasFingerprint
|
||||
)
|
||||
} else if (persisted !== derived.bindingFingerprint) {
|
||||
throw new Error('browser_route_partition_binding_conflict')
|
||||
}
|
||||
return derived.partition
|
||||
}
|
||||
|
||||
let directory: string
|
||||
|
||||
beforeEach(() => {
|
||||
configurePairedRuntimeBrowserClientHostsForOrcaProfile({ orcaProfileId: ORCA_PROFILE })
|
||||
directory = realpathSync(mkdtempSync(join(realpathSync(tmpdir()), 'wsl-host-route-')))
|
||||
bindings.filePath = join(directory, 'bindings.json')
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('WSL host browser route partition', () => {
|
||||
it('is the partition the host pages later use, across an orcad relaunch and re-pair', async () => {
|
||||
const seeded = bindWslHostBrowserRoutePartition(
|
||||
stableStorage(environment({ revision: 10, device: 'a' })),
|
||||
'default'
|
||||
)
|
||||
|
||||
const afterRelaunch = await pagePartition(environment({ revision: 20, device: 'b' }), 'rt-b')
|
||||
const afterAnother = await pagePartition(environment({ revision: 30, device: 'c' }), 'rt-c')
|
||||
|
||||
expect(afterRelaunch).toBe(seeded)
|
||||
expect(afterAnother).toBe(seeded)
|
||||
expect(
|
||||
bindWslHostBrowserRoutePartition(
|
||||
stableStorage(environment({ revision: 40, device: 'd' })),
|
||||
'default'
|
||||
)
|
||||
).toBe(seeded)
|
||||
})
|
||||
|
||||
it('copies into the jar the host already uses instead of starting another', async () => {
|
||||
const existing = await pagePartition(environment({ revision: 10, device: 'a' }), 'rt-a')
|
||||
|
||||
expect(
|
||||
bindWslHostBrowserRoutePartition(
|
||||
stableStorage(environment({ revision: 20, device: 'b' })),
|
||||
'default'
|
||||
)
|
||||
).toBe(existing)
|
||||
})
|
||||
|
||||
it('keeps one partition per browser profile', () => {
|
||||
const storage = stableStorage(environment({ revision: 10, device: 'a' }))
|
||||
expect(bindWslHostBrowserRoutePartition(storage, 'default')).not.toBe(
|
||||
bindWslHostBrowserRoutePartition(storage, 'work')
|
||||
)
|
||||
})
|
||||
|
||||
// Only this identity derives that name, so the page path re-adopts it; the copy must agree.
|
||||
it('keeps its own partition after an older build rewrote the binding without its alias', async () => {
|
||||
const storage = stableStorage(environment({ revision: 10, device: 'a' }))
|
||||
const partition = bindWslHostBrowserRoutePartition(storage, 'default')
|
||||
const store = new BrowserRoutePartitionBindingStore({ filePath: bindings.filePath })
|
||||
store.remove([partition])
|
||||
store.set(partition, 'f'.repeat(64), storage.storageScope)
|
||||
|
||||
expect(bindWslHostBrowserRoutePartition(storage, 'default')).toBe(partition)
|
||||
expect(await pagePartition(environment({ revision: 20, device: 'b' }), 'rt-b')).toBe(partition)
|
||||
})
|
||||
|
||||
it('reports no stable partition for an environment without a deployment link', () => {
|
||||
expect(
|
||||
wslHostBrowserRouteStorage(ORCA_PROFILE, environment({ revision: 10, device: 'a' }))
|
||||
).toMatchObject({ kind: 'stable' })
|
||||
expect(
|
||||
wslHostBrowserRouteStorage(ORCA_PROFILE, environment({ revision: 10, device: 'a' }, null))
|
||||
).toEqual({ kind: 'unavailable', reason: 'no-stable-partition' })
|
||||
})
|
||||
|
||||
it('never resolves a managed SSH host, so its partitions stay untouched', () => {
|
||||
expect(
|
||||
wslHostBrowserRouteStorage(
|
||||
ORCA_PROFILE,
|
||||
environment({ revision: 10, device: 'a' }, SSH_DEPLOYMENT, 'ssh-managed')
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'destination-not-wsl-host' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The route partition a WSL host's in-app browser pages will use, resolved from the environment
|
||||
* record alone so a caller can seed it while no page or orcad connection is live.
|
||||
*/
|
||||
import { classifyRuntimeEnvironmentHost } from '../../shared/runtime-environment-host-kind'
|
||||
import {
|
||||
getPreferredPairedRuntimeConnection,
|
||||
type KnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import { browserAuthorityExecutionHostStorageIdentity } from './browser-execution-host-storage-identity'
|
||||
import {
|
||||
deriveBrowserRoutePartitionStorageScope,
|
||||
type BrowserRoutePartitionIdentity
|
||||
} from './browser-route-identity'
|
||||
import { currentBrowserRoutePartitionBindingStore } from './browser-route-partition-binding-runtime'
|
||||
import { resolveBrowserRoutePartitionBinding } from './browser-route-partition-migration'
|
||||
import { isBrowserRoutePartitionRetainedByAnyOwner } from './browser-route-partition-retention'
|
||||
import { releaseEvictedBrowserRoutePartitionStorage } from './browser-route-partition-storage-dependencies'
|
||||
import { derivePairedRuntimeBrowserAuthorityIdentity } from './paired-runtime-browser-authority-identity'
|
||||
|
||||
// Only the per-process legacy identity reads the runtime id, and it is not used here.
|
||||
const OFFLINE_AUTHORITY_RUNTIME_ID = 'wsl-move-offline'
|
||||
|
||||
export type WslHostBrowserRouteStorage = {
|
||||
orcaProfileId: string
|
||||
storageScope: string
|
||||
/** Survives re-pairs; pages find the jar through it. */
|
||||
authorityConnectionIdentity: string
|
||||
/** The current pairing's identity, which the binding keeps as its fingerprint for downgrades. */
|
||||
pairingAuthorityConnectionIdentity: string
|
||||
}
|
||||
|
||||
export type WslHostBrowserRouteStorageResult =
|
||||
| { kind: 'stable'; storage: WslHostBrowserRouteStorage }
|
||||
| { kind: 'unavailable'; reason: 'destination-not-wsl-host' | 'no-stable-partition' }
|
||||
|
||||
/** `stable` only for a WSL host whose partition survives every orcad relaunch and re-pair. */
|
||||
export function wslHostBrowserRouteStorage(
|
||||
orcaProfileId: string,
|
||||
environment: KnownRuntimeEnvironment
|
||||
): WslHostBrowserRouteStorageResult {
|
||||
// Why first: without a deployment link the identity is pairing-scoped, so no host kind is stable.
|
||||
if (!environment.orcadDeployment) {
|
||||
return { kind: 'unavailable', reason: 'no-stable-partition' }
|
||||
}
|
||||
if (classifyRuntimeEnvironmentHost(environment).kind !== 'wsl') {
|
||||
return { kind: 'unavailable', reason: 'destination-not-wsl-host' }
|
||||
}
|
||||
const identity = derivePairedRuntimeBrowserAuthorityIdentity({
|
||||
orcaProfileId,
|
||||
environment,
|
||||
pairingRevision: environment.pairingRevision ?? environment.createdAt,
|
||||
authorityRuntimeId: OFFLINE_AUTHORITY_RUNTIME_ID,
|
||||
connection: getPreferredPairedRuntimeConnection(environment)
|
||||
})
|
||||
// Why: a pairing-scoped identity moves on the next orcad relaunch, stranding anything copied.
|
||||
if (identity.authorityConnectionIdentity === identity.pairingAuthorityConnectionIdentity) {
|
||||
return { kind: 'unavailable', reason: 'no-stable-partition' }
|
||||
}
|
||||
return {
|
||||
kind: 'stable',
|
||||
storage: {
|
||||
orcaProfileId,
|
||||
storageScope: deriveBrowserRoutePartitionStorageScope({
|
||||
orcaProfileId,
|
||||
environmentId: environment.id
|
||||
}),
|
||||
authorityConnectionIdentity: identity.authorityConnectionIdentity,
|
||||
pairingAuthorityConnectionIdentity: identity.pairingAuthorityConnectionIdentity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds (or adopts, as the first page would) the host's partition for one browser profile.
|
||||
* Throws `browser_route_partition_binding_conflict` when another identity owns the name.
|
||||
*/
|
||||
export function bindWslHostBrowserRoutePartition(
|
||||
storage: WslHostBrowserRouteStorage,
|
||||
browserProfileId: string
|
||||
): string {
|
||||
// Why: the host's own pages run natively inside the distro, so they name the authority itself.
|
||||
const executionHostIdentity = browserAuthorityExecutionHostStorageIdentity(storage.storageScope)
|
||||
const identity = (authorityConnectionIdentity: string): BrowserRoutePartitionIdentity => ({
|
||||
orcaProfileId: storage.orcaProfileId,
|
||||
browserProfileId,
|
||||
authorityConnectionIdentity,
|
||||
executionHostIdentity
|
||||
})
|
||||
const bindings = currentBrowserRoutePartitionBindingStore({
|
||||
isPartitionRetained: isBrowserRoutePartitionRetainedByAnyOwner
|
||||
})
|
||||
// Why no legacy identity: it hashes the live orcad's runtime id, so a jar bound to it is only
|
||||
// adoptable by a page of that same orcad process.
|
||||
const derived = resolveBrowserRoutePartitionBinding({
|
||||
bindings,
|
||||
identity: identity(storage.authorityConnectionIdentity),
|
||||
compatibleIdentity: identity(storage.pairingAuthorityConnectionIdentity),
|
||||
legacyIdentity: null,
|
||||
storageScope: storage.storageScope
|
||||
})
|
||||
const persisted = bindings.get(derived.partition)
|
||||
if (persisted === null) {
|
||||
const evicted = bindings.set(
|
||||
derived.partition,
|
||||
derived.bindingFingerprint,
|
||||
storage.storageScope,
|
||||
derived.aliasFingerprint
|
||||
)
|
||||
if (evicted.length > 0) {
|
||||
void releaseEvictedBrowserRoutePartitionStorage(
|
||||
evicted,
|
||||
isBrowserRoutePartitionRetainedByAnyOwner
|
||||
)
|
||||
}
|
||||
} else if (persisted !== derived.bindingFingerprint) {
|
||||
throw new Error('browser_route_partition_binding_conflict')
|
||||
} else {
|
||||
bindings.touch(derived.partition)
|
||||
}
|
||||
return derived.partition
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* R42: during the move to WSL hosts, the WSL bridge (installing from Windows) and the distro's own
|
||||
* orcad (installing locally) both reconcile Claude's hooks in the distro's ~/.claude. Both write
|
||||
* the one shared entry, so in either order the file ends with exactly one Orca entry per event,
|
||||
* and the user's own hooks survive.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getClaudeManagedHookPlan } from './claude-managed-hook-events'
|
||||
import { buildManagedCommandHook, type HooksConfig } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
applyManagedHooks,
|
||||
getManagedEventHook,
|
||||
getPosixManagedScriptFileName,
|
||||
getRemoteManagedEventCommand
|
||||
} from './hook-settings'
|
||||
|
||||
const HOME = '/home/dev'
|
||||
const SCRIPT = `${HOME}/.orca/agent-hooks/${getPosixManagedScriptFileName()}`
|
||||
const plan = getClaudeManagedHookPlan(null)
|
||||
const userHook = { matcher: '', hooks: [{ type: 'command' as const, command: 'notify-send done' }] }
|
||||
|
||||
const originalPlatform = process.platform
|
||||
beforeEach(() => {
|
||||
// orcad runs inside the distro: the local installer takes its Linux form.
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
})
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
})
|
||||
|
||||
const byHost = (config: HooksConfig): HooksConfig =>
|
||||
applyManagedHooks(
|
||||
config,
|
||||
(event) => getManagedEventHook(event, SCRIPT),
|
||||
getPosixManagedScriptFileName(),
|
||||
plan
|
||||
)
|
||||
const byBridge = (config: HooksConfig): HooksConfig =>
|
||||
applyManagedHooks(
|
||||
config,
|
||||
(event) => buildManagedCommandHook(getRemoteManagedEventCommand(event, SCRIPT)),
|
||||
getPosixManagedScriptFileName(),
|
||||
plan
|
||||
)
|
||||
|
||||
describe('Claude hooks written by the WSL bridge and the WSL host in one home', () => {
|
||||
it.each([
|
||||
['bridge then host', [byBridge, byHost]],
|
||||
['host then bridge', [byHost, byBridge]]
|
||||
] as const)('%s: one Orca entry per event, the user hook kept', (_order, writers) => {
|
||||
const start: HooksConfig = { hooks: { Stop: [userHook] } }
|
||||
const after = writers.reduce((config, write) => write(config), start)
|
||||
|
||||
for (const { eventName } of plan.install) {
|
||||
const commands = (after.hooks?.[eventName] ?? []).flatMap((entry) =>
|
||||
(entry.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
expect(
|
||||
commands.filter((command) => command.includes('agent-hooks/')),
|
||||
eventName
|
||||
).toHaveLength(1)
|
||||
}
|
||||
expect(after.hooks?.Stop).toContainEqual(userHook)
|
||||
// Each side rewrites the other's entry to the same bytes: no flapping between the two.
|
||||
expect(byHost(after)).toEqual(after)
|
||||
expect(byBridge(after)).toEqual(after)
|
||||
})
|
||||
})
|
||||
@@ -178,6 +178,8 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
|
||||
// Keep an unverified recovered cwd; fall back when the folder probe proves it missing.
|
||||
return {
|
||||
...restoreInfo,
|
||||
// Why one probe for WSL too: a stat over \\wsl.localhost waits for a stopped distro and
|
||||
// fails for reasons other than absence, which woke panes blank and at the root.
|
||||
cwd:
|
||||
recoveredCwd && (await probeRecoveredStartupCwdDirectory(recoveredCwd)) !== false
|
||||
? recoveredCwd
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import './mock-descendant-sweep'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { DaemonServer } from './daemon-server'
|
||||
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'
|
||||
import { inspectPtyProviderProcessForRenderer } from '../providers/pty-process-inspection'
|
||||
import { isClientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection'
|
||||
import { probePtyRunningWorkWithInspection } from '../../shared/pty-running-work-probe'
|
||||
|
||||
// After a relaunch every WSL bridge pane the window had not reopened read
|
||||
// `unverifiable`, so no move could pass its terminal gate.
|
||||
describe('inspecting a session that survived a relaunch unattached', () => {
|
||||
let dir: string
|
||||
let socketPath: string
|
||||
let tokenPath: string
|
||||
let server: DaemonServer
|
||||
let quittingApp: DaemonPtyAdapter
|
||||
let relaunchedApp: DaemonPtyAdapter | null = null
|
||||
const subprocesses: ReturnType<typeof createMockSubprocess>[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
subprocesses.length = 0
|
||||
const harness = await startDaemonAdapterHarness(() => {
|
||||
const subprocess = createMockSubprocess()
|
||||
subprocesses.push(subprocess)
|
||||
return subprocess
|
||||
})
|
||||
;({ dir, socketPath, tokenPath, server } = harness)
|
||||
quittingApp = harness.adapter
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const subprocess of subprocesses) {
|
||||
subprocess._simulateExit(0)
|
||||
}
|
||||
quittingApp.dispose()
|
||||
relaunchedApp?.dispose()
|
||||
relaunchedApp = null
|
||||
await server.shutdown()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// With one daemon generation, the relaunched app's provider is the adapter itself.
|
||||
async function relaunchWithSession(): Promise<{ provider: DaemonPtyAdapter; id: string }> {
|
||||
const { id } = await quittingApp.spawn({ cols: 80, rows: 24, sessionId: 'wt@@idle' })
|
||||
quittingApp.dispose()
|
||||
relaunchedApp = new DaemonPtyAdapter({ socketPath, tokenPath })
|
||||
// The relaunched runtime adopts surviving sessions from this inventory; no window attaches.
|
||||
expect((await relaunchedApp.listProcesses()).map((entry) => entry.id)).toEqual([id])
|
||||
return { provider: relaunchedApp, id }
|
||||
}
|
||||
|
||||
it('answers the WSL move gate from the daemon', async () => {
|
||||
const { provider, id } = await relaunchWithSession()
|
||||
const inspection = await inspectPtyProviderProcessForRenderer(provider, id, {
|
||||
scanChildProcesses: true,
|
||||
includeUnattached: true
|
||||
})
|
||||
expect(isClientOnlyUnverifiableInspection(inspection)).toBe(false)
|
||||
expect(inspection.hasChildProcesses).toBe(false)
|
||||
})
|
||||
|
||||
it('still reads as gone to every other caller', async () => {
|
||||
const { provider, id } = await relaunchWithSession()
|
||||
const inspection = await inspectPtyProviderProcessForRenderer(provider, id, {
|
||||
scanChildProcesses: true
|
||||
})
|
||||
expect(isClientOnlyUnverifiableInspection(inspection)).toBe(true)
|
||||
})
|
||||
|
||||
it('reads a session the daemon no longer runs as unverifiable even for the gate', async () => {
|
||||
const { provider, id } = await relaunchWithSession()
|
||||
subprocesses[0]!._simulateExit(0)
|
||||
const probe = (): Promise<string | undefined> =>
|
||||
probePtyRunningWorkWithInspection([id], { timeoutMs: 3_000 }, (ptyId) =>
|
||||
inspectPtyProviderProcessForRenderer(provider, ptyId, { includeUnattached: true })
|
||||
).then(([result]) => result?.verdict)
|
||||
await vi.waitFor(async () => expect(await probe()).toBe('unverifiable'))
|
||||
})
|
||||
|
||||
// Loss of contact is never evidence of exit.
|
||||
it('reads an unreachable daemon as unverifiable, never exited', async () => {
|
||||
const { provider, id } = await relaunchWithSession()
|
||||
await server.shutdown()
|
||||
const [result] = await probePtyRunningWorkWithInspection([id], { timeoutMs: 3_000 }, (ptyId) =>
|
||||
inspectPtyProviderProcessForRenderer(provider, ptyId, { includeUnattached: true })
|
||||
)
|
||||
expect(result?.verdict).toBe('unverifiable')
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ export async function readTerminalHistoryCheckpoint(
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFile {
|
||||
export function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFile {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
loadWslMoveFence,
|
||||
resetWslMoveFenceForTests
|
||||
} from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { assertWorkspaceAcceptsNewPty } from './spawn-cwd'
|
||||
import { assertWorkspaceAcceptsNewPty, localPtySpawnCwd } from './spawn-cwd'
|
||||
|
||||
const { listLiveDaemonPtyIds } = vi.hoisted(() => ({
|
||||
listLiveDaemonPtyIds: vi.fn<() => Promise<string[] | null>>()
|
||||
@@ -20,6 +20,8 @@ let userDataPath: string
|
||||
// No store: a readable journal never needs one.
|
||||
const accept = (worktreeId: string, sessionId?: string) =>
|
||||
Promise.resolve(assertWorkspaceAcceptsNewPty(undefined, worktreeId, sessionId))
|
||||
const acceptCwd = (cwd: string | undefined, worktreeId?: string, sessionId?: string) =>
|
||||
Promise.resolve(assertWorkspaceAcceptsNewPty(undefined, worktreeId, sessionId, cwd))
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-spawn-'))
|
||||
@@ -57,3 +59,66 @@ describe('assertWorkspaceAcceptsNewPty under a WSL move', () => {
|
||||
await expect(accept('repo-2::/x')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// R46: a terminal the workspace fence misses would otherwise outlive the commit in a moved folder.
|
||||
describe('a spawn by its folder, between quiesce and commit', () => {
|
||||
beforeEach(() => {
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'blobs-transferred'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the project root', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1'],
|
||||
['a folder inside it', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1\\src'],
|
||||
[
|
||||
'the wsl$ alias, distro case and a trailing separator',
|
||||
'\\\\wsl$\\UBUNTU\\home\\dev\\repo-1\\'
|
||||
],
|
||||
['the Linux spelling', '/home/dev/repo-1/src']
|
||||
])('refuses one that starts in %s', async (_label, cwd) => {
|
||||
await expect(acceptCwd(cwd)).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
})
|
||||
|
||||
it('lets a reload reattach a session the daemon still runs', async () => {
|
||||
listLiveDaemonPtyIds.mockResolvedValue(['bare-session'])
|
||||
await expect(acceptCwd('/home/dev/repo-1', undefined, 'bare-session')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a sibling whose name shares the prefix', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-10'],
|
||||
['the parent folder', '\\\\wsl.localhost\\Ubuntu\\home\\dev'],
|
||||
['the same folder in another distro', '\\\\wsl.localhost\\Debian\\home\\dev\\repo-1'],
|
||||
['a Windows folder', 'C:\\Users\\dev\\repo-1'],
|
||||
['another Linux folder', '/home/dev/other'],
|
||||
['no folder at all', undefined]
|
||||
])('leaves one alone that starts in %s', async (_label, cwd) => {
|
||||
await expect(acceptCwd(cwd)).resolves.toBeUndefined()
|
||||
expect(listLiveDaemonPtyIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a floating terminal', 'global-floating-terminal'],
|
||||
['another project', 'repo-2::/x'],
|
||||
['a folder workspace', 'folder:folder-9']
|
||||
])('refuses %s that starts in a moving folder', async (_label, worktreeId) => {
|
||||
await expect(acceptCwd('/home/dev/repo-1/src', worktreeId)).rejects.toBeInstanceOf(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves another workspace alone that starts outside the moving folders', async () => {
|
||||
await expect(acceptCwd('/home/dev/other', 'repo-2::/x')).resolves.toBeUndefined()
|
||||
await expect(acceptCwd('C:\\work', 'folder:folder-9')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('never reads the folder of an SSH spawn, which runs on another machine', () => {
|
||||
expect(localPtySpawnCwd({ connectionId: 'ssh-1', cwd: '/home/dev/repo-1' })).toBeUndefined()
|
||||
expect(localPtySpawnCwd({ connectionId: null, cwd: '/home/dev/repo-1' })).toBe(
|
||||
'/home/dev/repo-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('stops at the commit, once the host owns the folder', async () => {
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
await expect(acceptCwd('/home/dev/repo-1')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,18 +11,26 @@ import {
|
||||
getFolderWorkspacePathStatus
|
||||
} from '../../../project-groups/folder-workspace-path-status'
|
||||
import {
|
||||
isWslMoveCwdFenced,
|
||||
isWslMoveWorkspaceBlocked,
|
||||
WslMoveProjectFencedError
|
||||
} from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { listLiveDaemonPtyIds } from '../../../daemon/daemon-provider-state'
|
||||
|
||||
/** WSL moves block new PTYs, but proven daemon reattach must survive reload; folder workspaces still require usable paths. */
|
||||
/**
|
||||
* A new PTY needs a workspace no WSL move is taking, and a folder that still exists. Reattaching to
|
||||
* a session the daemon still runs is not a new PTY: a reload mid-move must not orphan a live pane.
|
||||
* The requested folder counts too: a terminal from anywhere that starts inside a moving project
|
||||
* would outlive the move there (a floating terminal, a Windows project's WSL tab, `--cwd`).
|
||||
*/
|
||||
export function assertWorkspaceAcceptsNewPty(
|
||||
store: Store | undefined,
|
||||
worktreeId: string | undefined,
|
||||
sessionId?: string
|
||||
sessionId?: string,
|
||||
cwd?: string
|
||||
): Promise<void> | void {
|
||||
if (isWslMoveWorkspaceBlocked(store, worktreeId)) {
|
||||
const fencedByCwd = !!cwd && isWslMoveCwdFenced(cwd)
|
||||
if (fencedByCwd || isWslMoveWorkspaceBlocked(store, worktreeId)) {
|
||||
return assertReattachesLiveSession(sessionId).then(() =>
|
||||
assertFolderWorkspacePtyPathUsable(store, worktreeId)
|
||||
)
|
||||
@@ -30,6 +38,14 @@ export function assertWorkspaceAcceptsNewPty(
|
||||
return assertFolderWorkspacePtyPathUsable(store, worktreeId)
|
||||
}
|
||||
|
||||
/** Only a spawn on this machine starts in a folder a WSL move can be taking. */
|
||||
export function localPtySpawnCwd(args: {
|
||||
connectionId?: string | null
|
||||
cwd?: string
|
||||
}): string | undefined {
|
||||
return args.connectionId ? undefined : args.cwd
|
||||
}
|
||||
|
||||
async function assertReattachesLiveSession(sessionId: string | undefined): Promise<void> {
|
||||
// An unanswered census is never proof the session lives, so it refuses.
|
||||
const live = sessionId ? await listLiveDaemonPtyIds() : null
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { getProvider } from '../provider/registry'
|
||||
import { resolvePtySpawnStartupTarget } from '../spawn-startup-target'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import { localPtySpawnCwd } from '../host-env/spawn-cwd'
|
||||
import { getConnectionExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise<void> {
|
||||
@@ -30,7 +31,11 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis
|
||||
ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true)
|
||||
}
|
||||
if (!ctx.earlyStablePaneOwner) {
|
||||
const admission = ctx.deps.assertWorkspaceAcceptsNewPty(args.worktreeId, args.sessionId)
|
||||
const admission = ctx.deps.assertWorkspaceAcceptsNewPty(
|
||||
args.worktreeId,
|
||||
args.sessionId,
|
||||
localPtySpawnCwd(args)
|
||||
)
|
||||
if (admission) {
|
||||
await admission
|
||||
}
|
||||
@@ -39,7 +44,11 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis
|
||||
ctx.preAdoptedStablePane = null
|
||||
ctx.spawnTiming.mark('stable_adoption')
|
||||
if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) {
|
||||
const admission = ctx.deps.assertWorkspaceAcceptsNewPty(args.worktreeId, args.sessionId)
|
||||
const admission = ctx.deps.assertWorkspaceAcceptsNewPty(
|
||||
args.worktreeId,
|
||||
args.sessionId,
|
||||
localPtySpawnCwd(args)
|
||||
)
|
||||
if (admission) {
|
||||
await admission
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ export type PtySpawnIpcDeps = PtySpawnCodexResumeDeps & {
|
||||
adoptStablePane: (args: AdoptStablePaneArgs) => Promise<AdoptStablePaneResult | null>
|
||||
assertWorkspaceAcceptsNewPty: (
|
||||
worktreeId: string | undefined,
|
||||
sessionId?: string
|
||||
sessionId?: string,
|
||||
cwd?: string
|
||||
) => Promise<void> | void
|
||||
resolvePtySpawnStartupCwd: (
|
||||
worktreeId: string | undefined,
|
||||
|
||||
@@ -217,8 +217,9 @@ export function registerPtyHandlers(
|
||||
|
||||
const assertWorkspaceAcceptsNewPty = (
|
||||
worktreeId: string | undefined,
|
||||
sessionId?: string
|
||||
): Promise<void> | void => assertWorkspaceAcceptsNewPtyImpl(store, worktreeId, sessionId)
|
||||
sessionId?: string,
|
||||
cwd?: string
|
||||
): Promise<void> | void => assertWorkspaceAcceptsNewPtyImpl(store, worktreeId, sessionId, cwd)
|
||||
const resolvePtySpawnStartupCwd = (
|
||||
worktreeId: string | undefined,
|
||||
cwd: string | undefined,
|
||||
|
||||
@@ -24,7 +24,8 @@ export type PtyRuntimeControllerDeps = PtySpawnCodexResumeDeps & {
|
||||
getLocalPtyProviderStartupPromise: (hostId?: ResolvedPtyHost) => Promise<void> | undefined
|
||||
assertWorkspaceAcceptsNewPty: (
|
||||
worktreeId: string | undefined,
|
||||
sessionId?: string
|
||||
sessionId?: string,
|
||||
cwd?: string
|
||||
) => Promise<void> | void
|
||||
resolvePtySpawnStartupCwd: (
|
||||
worktreeId: string | undefined,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../../daemon/pty-session-id'
|
||||
import { resolvePathEnvKey } from '../../../pty/windows-environment-path'
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
import { localPtySpawnCwd } from '../host-env/spawn-cwd'
|
||||
import { resolvePtySpawnStartupTarget } from '../spawn-startup-target'
|
||||
import { resolvePtySpawnHostEnv } from '../spawn-host-env'
|
||||
import { ptySpawnPaneFromIds, stampPtySpawnPaneEnv } from '../spawn-pane-env'
|
||||
@@ -29,7 +30,11 @@ export async function prepareRuntimePtySpawn(
|
||||
): Promise<PtySpawnResult | null> {
|
||||
const args = ctx.args
|
||||
if (!ctx.preAdoptedStablePane) {
|
||||
const admission = ctx.deps.assertWorkspaceAcceptsNewPty(args.worktreeId, args.sessionId)
|
||||
const admission = ctx.deps.assertWorkspaceAcceptsNewPty(
|
||||
args.worktreeId,
|
||||
args.sessionId,
|
||||
localPtySpawnCwd(args)
|
||||
)
|
||||
if (admission) {
|
||||
await admission
|
||||
}
|
||||
|
||||
@@ -148,7 +148,8 @@ vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: getPathMock,
|
||||
once: vi.fn()
|
||||
}
|
||||
},
|
||||
webContents: { fromId: () => undefined }
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/runtime-environment-store', () => ({
|
||||
@@ -325,6 +326,10 @@ vi.mock('../wsl-move-handlers', () => ({
|
||||
registerWslMoveHandlers: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../wsl-move-orchestrator-wiring', () => ({
|
||||
registerWslMoveOrchestrator: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ephemeral-vm', () => ({
|
||||
registerEphemeralVmHandlers: registerEphemeralVmHandlersMock
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from 'electron'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { registerAppHandlers } from '../app'
|
||||
import { registerWslMoveWiring } from '../wsl-move-driver-wiring'
|
||||
import { registerCliHandlers } from '../cli'
|
||||
import { registerPreflightHandlers } from '../preflight'
|
||||
import type { Store } from '../../persistence/loading-store/store'
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
registerRuntimeEnvironmentHandlers
|
||||
} from '../runtime-environments/runtime-environments'
|
||||
import { registerWslHostHandlers } from '../wsl-host-handlers'
|
||||
import { registerWslMoveHandlers } from '../wsl-move-handlers'
|
||||
import { registerEphemeralVmHandlers } from '../ephemeral-vm'
|
||||
import { registerAiVaultHandlers } from '../ai-vault'
|
||||
import { registerAiVaultSearchHandlers } from '../ai-vault-search'
|
||||
@@ -262,7 +262,7 @@ export function registerCoreHandlers(
|
||||
getUserDataPath: () => app.getPath('userData'),
|
||||
invalidateTransport: invalidateRuntimeEnvironmentTransport
|
||||
})
|
||||
registerWslMoveHandlers(() => app.getPath('userData'))
|
||||
registerWslMoveWiring(store, runtime)
|
||||
registerEphemeralVmHandlers(store, pluginService)
|
||||
// Session history and terminal resume are not chats; a refused host leaves nothing to check.
|
||||
const ensureStructuredSessionOwnership = () =>
|
||||
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
registerRendererShutdownCheckpointHandler,
|
||||
SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS
|
||||
} from './renderer-shutdown-checkpoint'
|
||||
import {
|
||||
resetWslMoveWindowWriteHoldForTests,
|
||||
holdWslMoveWindowWrites,
|
||||
setWslMoveReloadableWindow
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-window-write-hold'
|
||||
|
||||
const AWAIT_CHANNEL = 'app:await-before-unload-checkpoint'
|
||||
|
||||
@@ -90,6 +95,39 @@ describe('registerRendererShutdownCheckpointHandler', () => {
|
||||
expect(event.returnValue).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('stages nothing of a window a WSL move holds, and still flushes', () => {
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()),
|
||||
stageWorkspaceSessionBeforeUnload: vi.fn(),
|
||||
updateUI: vi.fn(),
|
||||
flushPendingOrThrowAsync: vi.fn(() => Promise.resolve())
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only calls the four methods stubbed above.
|
||||
registerRendererShutdownCheckpointHandler(store as never)
|
||||
setWslMoveReloadableWindow(() => ({
|
||||
isDestroyed: () => false,
|
||||
reload: () => {},
|
||||
allowUnload: () => () => {},
|
||||
onLoadedOrGone: () => () => {}
|
||||
}))
|
||||
holdWslMoveWindowWrites()
|
||||
try {
|
||||
const event: { returnValue?: unknown } = {}
|
||||
syncHandlers.get('app:stage-before-unload-sync')?.(event, {
|
||||
sessions: [{ state: {} }],
|
||||
ui: { activeView: 'settings' }
|
||||
})
|
||||
|
||||
expect(store.stageWorkspaceSessionBeforeUnload).not.toHaveBeenCalled()
|
||||
expect(store.updateUI).not.toHaveBeenCalled()
|
||||
expect(store.flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
|
||||
expect(event.returnValue).toEqual({ ok: true })
|
||||
} finally {
|
||||
resetWslMoveWindowWriteHoldForTests()
|
||||
setWslMoveReloadableWindow(() => null)
|
||||
}
|
||||
})
|
||||
|
||||
it('never stages the source partition of a fenced host', () => {
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()),
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source'
|
||||
import { isWslMoveWindowWriteHeld } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold'
|
||||
import { windowViewSaveForMain, type ReconnectFactsFor } from '../persistence/window-save-view'
|
||||
import { reportWindowSaveDrift } from '../persistence/window-save-divergence'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
@@ -102,7 +103,9 @@ export function registerRendererShutdownCheckpointHandler(
|
||||
ipcMain.on('app:stage-before-unload-sync', (event, args: StageBeforeUnloadSyncArgs) => {
|
||||
let error: string | undefined
|
||||
try {
|
||||
for (const { state, hostId } of args.sessions) {
|
||||
// A window a WSL move rewrote under is stale; main reloads it and keeps what is on disk.
|
||||
const sessions = isWslMoveWindowWriteHeld() ? [] : args.sessions
|
||||
for (const { state, hostId } of sessions) {
|
||||
if (!isFrozenOrcadSourceSessionPartition(store, hostId)) {
|
||||
store.stageWorkspaceSessionBeforeUnload(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
@@ -112,7 +115,9 @@ export function registerRendererShutdownCheckpointHandler(
|
||||
)
|
||||
}
|
||||
}
|
||||
store.updateUI(args.ui)
|
||||
if (!isWslMoveWindowWriteHeld()) {
|
||||
store.updateUI(args.ui)
|
||||
}
|
||||
} catch (stagingError) {
|
||||
console.error('[app] Failed to stage renderer state before unload:', stagingError)
|
||||
// Main's console is not persisted in packaged builds; the renderer toast is the only place users see the cause.
|
||||
|
||||
@@ -21,6 +21,7 @@ export function registerRepoHandlers(
|
||||
): void {
|
||||
// macOS window recreation re-registers these handlers.
|
||||
ipcMain.removeHandler('repos:list')
|
||||
ipcMain.removeHandler('repos:listWslMoved')
|
||||
ipcMain.removeHandler('repos:add')
|
||||
ipcMain.removeHandler('repos:removeForHost')
|
||||
ipcMain.removeHandler('repos:reorder')
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
assertWslMoveReposWritable,
|
||||
isWslMoveSourceHost
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { listWslMovedProjects } from '../../persistence/moving-wsl-projects/wsl-moved-projects'
|
||||
|
||||
export function registerRepoCatalogHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
@@ -36,6 +37,11 @@ export function registerRepoCatalogHandlers(
|
||||
return visibleRepos(store)
|
||||
})
|
||||
|
||||
// The local rows `repos:list` hides once their WSL host owns them; read-only, for their chats.
|
||||
ipcMain.handle('repos:listWslMoved', () =>
|
||||
listWslMovedProjects(store.getRepos(), store.getFolderWorkspaces())
|
||||
)
|
||||
|
||||
ipcMain.handle('projects:list', () => {
|
||||
enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged })
|
||||
return store.getProjects()
|
||||
|
||||
@@ -50,6 +50,10 @@ import {
|
||||
wslOrcadHostSupports
|
||||
} from '../../wsl/wsl-orcad-host-environments'
|
||||
import { assertWslHostReachable, isGatedOffWslHost } from '../../wsl/wsl-host-gate'
|
||||
import {
|
||||
isHostSpawnFencedForWslMoveUndo,
|
||||
wslMoveUndoSpawnFencedResponse
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-undo-spawn-fence'
|
||||
|
||||
export { isRuntimeEnvironmentManuallyDisconnected }
|
||||
|
||||
@@ -229,6 +233,9 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
|
||||
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
|
||||
return manuallyDisconnectedResponse(environment)
|
||||
}
|
||||
if (isHostSpawnFencedForWslMoveUndo(environment.id, args.method)) {
|
||||
return wslMoveUndoSpawnFencedResponse(environment, args.method)
|
||||
}
|
||||
void desktopChatCreationHistory(getUserDataPath()).recordCreate(
|
||||
environment.id,
|
||||
args.method,
|
||||
|
||||
+13
-2
@@ -3,6 +3,7 @@ import type { Store } from '../persistence'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { parseTerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target'
|
||||
import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source'
|
||||
import { isWslMoveWindowWriteHeld } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold'
|
||||
import { visibleSessionHostIds } from '../ssh/orcad-retained-source-session'
|
||||
import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment'
|
||||
import { windowViewPatchForMain, windowViewSaveForMain } from '../persistence/window-save-view'
|
||||
@@ -15,9 +16,10 @@ import type {
|
||||
} from '../../shared/workspace-session-state-types'
|
||||
|
||||
export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeService): void {
|
||||
// Renderer saves must not mutate a fenced source host partition.
|
||||
// Why: renderer saves would change a fenced host's frozen source partition, or write back what a
|
||||
// WSL move rewrote under this window before main reloads it.
|
||||
const isFenced = (hostId?: string | null): boolean =>
|
||||
isFrozenOrcadSourceSessionPartition(store, hostId)
|
||||
isFrozenOrcadSourceSessionPartition(store, hostId) || isWslMoveWindowWriteHeld()
|
||||
|
||||
// Optional hostId preserves local-partition behavior for older renderers.
|
||||
ipcMain.handle('session:get', (_event, hostId?: string | null) => {
|
||||
@@ -100,4 +102,13 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic
|
||||
typeof args?.ref === 'string' ? store.readTerminalScrollbackSnapshot(args.ref) : null
|
||||
}
|
||||
)
|
||||
|
||||
// Why: render-time planning only needs to know a local copy exists; a stat never reads bytes.
|
||||
ipcMain.on(
|
||||
'session:has-terminal-scrollback-sync',
|
||||
(event, args: { ref?: unknown } | undefined) => {
|
||||
event.returnValue =
|
||||
typeof args?.ref === 'string' ? store.hasTerminalScrollbackSnapshot(args.ref) : false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
|
||||
'ssh_host_server_deploy_failed',
|
||||
'ssh_host_server_move',
|
||||
'ssh_host_server_legacy_relay_bridge',
|
||||
'wsl_move',
|
||||
'wsl_move_orchestrator',
|
||||
'wsl_usage_snapshot'
|
||||
])
|
||||
|
||||
|
||||
+5
-1
@@ -9,6 +9,7 @@ import { BrowserWindow, ipcMain, webContents, type WebContents } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
|
||||
import { isFeatureInteractionId } from '../../shared/feature-interactions'
|
||||
import { isWslMoveWindowWriteHeld } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold'
|
||||
import { isTrustedMainRendererSender } from './main-renderer-sender-trust'
|
||||
|
||||
let trustedUIRendererWebContentsId: number | null = null
|
||||
@@ -87,7 +88,10 @@ export function registerUIHandlers(
|
||||
})
|
||||
|
||||
ipcMain.handle('ui:set', (_event, args: Partial<PersistedUIState>) => {
|
||||
store.updateUI(args)
|
||||
// A window a WSL move rewrote under is stale until main reloads it.
|
||||
if (!isWslMoveWindowWriteHeld()) {
|
||||
store.updateUI(args)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('ui:recordFeatureInteraction', (_event, id: unknown) => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Undo's host step for automations: a released move's automations run here again, so the host's
|
||||
* copies turn off first, or each would run twice, once on each side of the same files.
|
||||
*/
|
||||
import {
|
||||
AUTOMATION_NOT_FOUND,
|
||||
ShownAutomationSchema,
|
||||
openWslHostAutomationApi,
|
||||
type WslHostAutomationApi
|
||||
} from './wsl-host-automation-enable'
|
||||
|
||||
/** A disable that refused partway and could not turn back on `leftOff`, which stay off. */
|
||||
export class WslHostAutomationsLeftOffError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly leftOff: string[]
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Each one's state on the host, the user's latest choice; one deleted there reads as off. */
|
||||
export async function readWslHostAutomationStates(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
automationIds: readonly string[]
|
||||
): Promise<Record<string, boolean>> {
|
||||
const states: Record<string, boolean> = {}
|
||||
if (automationIds.length === 0) {
|
||||
return states
|
||||
}
|
||||
const request = await openWslHostAutomationApi(userDataPath, environmentId)
|
||||
for (const id of automationIds) {
|
||||
const shown = await request('automation.show', { id })
|
||||
if (!shown.ok) {
|
||||
if (!shown.error.message.includes(AUTOMATION_NOT_FOUND)) {
|
||||
throw new Error(`wsl_move_automation_read_failed:${shown.error.message}`)
|
||||
}
|
||||
states[id] = false
|
||||
continue
|
||||
}
|
||||
states[id] = ShownAutomationSchema.parse(shown.result).automation.enabled
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns each off and returns them. On a refusal it turns back on the ones it had turned off,
|
||||
* then throws; any it could not turn back on ride the error as `leftOff`.
|
||||
*/
|
||||
export async function disableWslHostAutomations(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
automationIds: readonly string[]
|
||||
): Promise<string[]> {
|
||||
if (automationIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const request = await openWslHostAutomationApi(userDataPath, environmentId)
|
||||
const turnedOff: string[] = []
|
||||
try {
|
||||
for (const id of automationIds) {
|
||||
const updated = await request('automation.update', { id, updates: { enabled: false } })
|
||||
if (!updated.ok) {
|
||||
throw new Error(`wsl_move_automation_disable_failed:${updated.error.message}`)
|
||||
}
|
||||
turnedOff.push(id)
|
||||
}
|
||||
} catch (error) {
|
||||
// A refused undo leaves the host as it found it (X1), as far as the host lets it.
|
||||
const leftOff = await turnOnEach(request, turnedOff)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw leftOff.length > 0 ? new WslHostAutomationsLeftOffError(message, leftOff) : error
|
||||
}
|
||||
return turnedOff
|
||||
}
|
||||
|
||||
/** Turns back on what disableWslHostAutomations turned off, when undo then refuses. */
|
||||
export async function restoreWslHostAutomations(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
automationIds: readonly string[]
|
||||
): Promise<void> {
|
||||
if (automationIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const request = await openWslHostAutomationApi(userDataPath, environmentId)
|
||||
const leftOff = await turnOnEach(request, automationIds)
|
||||
if (leftOff.length > 0) {
|
||||
throw new WslHostAutomationsLeftOffError('wsl_move_automation_enable_failed', leftOff)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids it could not turn on. A refusal skips just that one; a host that stops answering ends
|
||||
* the pass, leaving the rest off, rather than hold undo's turn waiting on each.
|
||||
*/
|
||||
async function turnOnEach(
|
||||
request: WslHostAutomationApi,
|
||||
ids: readonly string[]
|
||||
): Promise<string[]> {
|
||||
const leftOff: string[] = []
|
||||
for (const [index, id] of ids.entries()) {
|
||||
try {
|
||||
const updated = await request('automation.update', { id, updates: { enabled: true } })
|
||||
if (!updated.ok) {
|
||||
leftOff.push(id)
|
||||
}
|
||||
} catch {
|
||||
return [...leftOff, ...ids.slice(index)]
|
||||
}
|
||||
}
|
||||
return leftOff
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hostRows = vi.hoisted(() => new Map<string, { enabled: boolean; updatedAt: number }>())
|
||||
const updates = vi.hoisted((): string[] => [])
|
||||
const failNextUpdate = vi.hoisted(() => ({ value: false }))
|
||||
const throwOnUpdate = vi.hoisted(() => ({ value: false }))
|
||||
const updateAttempts = vi.hoisted(() => ({ count: 0 }))
|
||||
const failOnUpdateOf = vi.hoisted((): { id: string | null; thenAlso: string | null } => ({
|
||||
id: null,
|
||||
thenAlso: null
|
||||
}))
|
||||
|
||||
vi.mock('../wsl/wsl-host-service-instance', () => ({
|
||||
getWslHostService: () => ({
|
||||
recordForEnvironment: () => ({ distro: 'Ubuntu' }),
|
||||
ensureForRuntimeCall: async () => {}
|
||||
})
|
||||
}))
|
||||
vi.mock('../../shared/runtime-environment-store', () => ({
|
||||
resolveEnvironment: () => ({ id: 'wsl:Ubuntu', runtimeId: 'rt-1' })
|
||||
}))
|
||||
vi.mock('../../shared/runtime-environments', () => ({ getPreferredPairingOffer: () => ({}) }))
|
||||
vi.mock('../../shared/pairing', () => ({
|
||||
encodePairingOffer: () => 'code',
|
||||
parsePairingCode: () => ({ code: 'code' })
|
||||
}))
|
||||
vi.mock('../ssh/orcad-migration-catalog-client', () => ({
|
||||
sendSupportedMigrationRequest: async (
|
||||
_pairing: unknown,
|
||||
method: string,
|
||||
params: { id: string; updates?: { enabled: boolean } }
|
||||
) => {
|
||||
const row = hostRows.get(params.id)
|
||||
if (method === 'automation.show') {
|
||||
return row
|
||||
? { ok: true, result: { automation: row } }
|
||||
: { ok: false, error: { message: 'Automation not found.' } }
|
||||
}
|
||||
updateAttempts.count++
|
||||
if (throwOnUpdate.value) {
|
||||
throw new Error('connect ECONNREFUSED')
|
||||
}
|
||||
if (failNextUpdate.value || failOnUpdateOf.id === params.id) {
|
||||
failNextUpdate.value = false
|
||||
// Then the host drops: the next update (turning one back on) fails too.
|
||||
failOnUpdateOf.id = failOnUpdateOf.thenAlso
|
||||
failOnUpdateOf.thenAlso = null
|
||||
return { ok: false, error: { message: 'busy' } }
|
||||
}
|
||||
updates.push(params.id)
|
||||
if (row) {
|
||||
row.enabled = params.updates?.enabled ?? true
|
||||
}
|
||||
return { ok: true, result: {} }
|
||||
}
|
||||
}))
|
||||
|
||||
import { enableWslHostAutomations } from './wsl-host-automation-enable'
|
||||
import {
|
||||
disableWslHostAutomations,
|
||||
readWslHostAutomationStates,
|
||||
restoreWslHostAutomations
|
||||
} from './wsl-host-automation-disable'
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.clear()
|
||||
updates.length = 0
|
||||
throwOnUpdate.value = false
|
||||
updateAttempts.count = 0
|
||||
})
|
||||
|
||||
describe('enableWslHostAutomations', () => {
|
||||
it('turns on only automations still exactly as the move left them', async () => {
|
||||
hostRows.set('untouched', { enabled: false, updatedAt: 10 })
|
||||
hostRows.set('edited-on-host', { enabled: false, updatedAt: 99 })
|
||||
hostRows.set('already-on', { enabled: true, updatedAt: 10 })
|
||||
await enableWslHostAutomations('/user-data', 'wsl:Ubuntu', [
|
||||
{ id: 'untouched', updatedAt: 10 },
|
||||
{ id: 'edited-on-host', updatedAt: 10 },
|
||||
{ id: 'already-on', updatedAt: 10 },
|
||||
{ id: 'deleted-on-host', updatedAt: 10 }
|
||||
])
|
||||
expect(updates).toEqual(['untouched'])
|
||||
})
|
||||
|
||||
it('throws when the host refuses one, so the step stays pending and retries', async () => {
|
||||
hostRows.set('a', { enabled: false, updatedAt: 1 })
|
||||
failNextUpdate.value = true
|
||||
await expect(
|
||||
enableWslHostAutomations('/user-data', 'wsl:Ubuntu', [{ id: 'a', updatedAt: 1 }])
|
||||
).rejects.toThrow('wsl_move_automation_enable_failed:busy')
|
||||
await enableWslHostAutomations('/user-data', 'wsl:Ubuntu', [{ id: 'a', updatedAt: 1 }])
|
||||
expect(updates).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('undo on the host', () => {
|
||||
it("reads each automation's state there; one deleted there reads as off", async () => {
|
||||
hostRows.set('on', { enabled: true, updatedAt: 10 })
|
||||
hostRows.set('off', { enabled: false, updatedAt: 10 })
|
||||
expect(
|
||||
await readWslHostAutomationStates('/user-data', 'wsl:Ubuntu', ['on', 'off', 'deleted'])
|
||||
).toEqual({ on: true, off: false, deleted: false })
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
it('turns them off, and restore turns them back on', async () => {
|
||||
hostRows.set('on', { enabled: true, updatedAt: 10 })
|
||||
expect(await disableWslHostAutomations('/user-data', 'wsl:Ubuntu', ['on'])).toEqual(['on'])
|
||||
expect(hostRows.get('on')?.enabled).toBe(false)
|
||||
await restoreWslHostAutomations('/user-data', 'wsl:Ubuntu', ['on'])
|
||||
expect(hostRows.get('on')?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('turns back on what it turned off before throwing, so a refused undo changes nothing', async () => {
|
||||
hostRows.set('a', { enabled: true, updatedAt: 1 })
|
||||
hostRows.set('b', { enabled: true, updatedAt: 1 })
|
||||
failOnUpdateOf.id = 'b'
|
||||
await expect(disableWslHostAutomations('/user-data', 'wsl:Ubuntu', ['a', 'b'])).rejects.toThrow(
|
||||
'wsl_move_automation_disable_failed:busy'
|
||||
)
|
||||
expect(hostRows.get('a')?.enabled).toBe(true)
|
||||
expect(hostRows.get('b')?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('carries what it could not turn back on on the error', async () => {
|
||||
hostRows.set('a', { enabled: true, updatedAt: 1 })
|
||||
hostRows.set('b', { enabled: true, updatedAt: 1 })
|
||||
failOnUpdateOf.id = 'b'
|
||||
failOnUpdateOf.thenAlso = 'a'
|
||||
const error = await disableWslHostAutomations('/user-data', 'wsl:Ubuntu', ['a', 'b']).catch(
|
||||
(thrown: unknown) => thrown
|
||||
)
|
||||
expect(error).toMatchObject({ leftOff: ['a'] })
|
||||
expect(hostRows.get('a')?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('asks the host nothing when no automation needs turning off', async () => {
|
||||
expect(await disableWslHostAutomations('/user-data', 'wsl:Ubuntu', [])).toEqual([])
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
it('stops at a host that stops answering, leaving the rest off rather than wait on each', async () => {
|
||||
for (const id of ['a', 'b', 'c']) {
|
||||
hostRows.set(id, { enabled: false, updatedAt: 1 })
|
||||
}
|
||||
throwOnUpdate.value = true
|
||||
const error = await restoreWslHostAutomations('/user-data', 'wsl:Ubuntu', [
|
||||
'a',
|
||||
'b',
|
||||
'c'
|
||||
]).catch((thrown: unknown) => thrown)
|
||||
expect(error).toMatchObject({ leftOff: ['a', 'b', 'c'] })
|
||||
expect(updateAttempts.count).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
/** A WSL move's last host step: the moved automations go back on, through the host's own API. */
|
||||
import { z } from 'zod'
|
||||
import { encodePairingOffer, parsePairingCode } from '../../shared/pairing'
|
||||
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
|
||||
import { resolveEnvironment } from '../../shared/runtime-environment-store'
|
||||
import { sendSupportedMigrationRequest } from '../ssh/orcad-migration-catalog-client'
|
||||
import { getWslHostService } from '../wsl/wsl-host-service-instance'
|
||||
import type { WslMoveMovedAutomation } from '../persistence/moving-wsl-projects/wsl-move-host-automations-rewrite'
|
||||
|
||||
// The host's automation service message for an id it does not hold.
|
||||
export const AUTOMATION_NOT_FOUND = 'Automation not found'
|
||||
|
||||
export const ShownAutomationSchema = z.object({
|
||||
automation: z.object({ enabled: z.boolean(), updatedAt: z.number() })
|
||||
})
|
||||
|
||||
export type WslHostAutomationApi = (
|
||||
method: 'automation.show' | 'automation.update',
|
||||
params: unknown
|
||||
) => ReturnType<typeof sendSupportedMigrationRequest>
|
||||
|
||||
/** The host's automation API, never booting its distro; throws when the host is not reachable. */
|
||||
export async function openWslHostAutomationApi(
|
||||
userDataPath: string,
|
||||
environmentId: string
|
||||
): Promise<WslHostAutomationApi> {
|
||||
const service = getWslHostService()
|
||||
if (!service?.recordForEnvironment(environmentId)) {
|
||||
throw new Error('wsl_move_host_unavailable')
|
||||
}
|
||||
await service.ensureForRuntimeCall(environmentId)
|
||||
const environment = resolveEnvironment(userDataPath, environmentId)
|
||||
const pairing = parsePairingCode(encodePairingOffer(getPreferredPairingOffer(environment)))
|
||||
if (!pairing) {
|
||||
throw new Error('orcad_migration_pairing_code_invalid')
|
||||
}
|
||||
const options = environment.runtimeId ? { expectedRuntimeId: environment.runtimeId } : {}
|
||||
return (method, params) => sendSupportedMigrationRequest(pairing, method, params, options)
|
||||
}
|
||||
|
||||
export async function enableWslHostAutomations(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
automations: readonly WslMoveMovedAutomation[]
|
||||
): Promise<void> {
|
||||
const request = await openWslHostAutomationApi(userDataPath, environmentId)
|
||||
for (const moved of automations) {
|
||||
const shown = await request('automation.show', { id: moved.id })
|
||||
if (!shown.ok) {
|
||||
// Deleted on the host since the move: nothing left to turn on.
|
||||
if (shown.error.message.includes(AUTOMATION_NOT_FOUND)) {
|
||||
continue
|
||||
}
|
||||
throw new Error(`wsl_move_automation_read_failed:${shown.error.message}`)
|
||||
}
|
||||
const row = ShownAutomationSchema.parse(shown.result).automation
|
||||
// On already, or edited on the host since the move: the host's state is the user's choice.
|
||||
if (row.enabled || moved.updatedAt === null || row.updatedAt !== moved.updatedAt) {
|
||||
continue
|
||||
}
|
||||
const updated = await request('automation.update', { id: moved.id, updates: { enabled: true } })
|
||||
if (!updated.ok) {
|
||||
throw new Error(`wsl_move_automation_enable_failed:${updated.error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Handler = (event: unknown, request?: unknown) => unknown
|
||||
|
||||
const handlers = vi.hoisted(() => new Map<string, Handler>())
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] },
|
||||
ipcMain: {
|
||||
removeHandler: (channel: string) => handlers.delete(channel),
|
||||
handle: (channel: string, handler: Handler) => handlers.set(channel, handler)
|
||||
}
|
||||
}))
|
||||
vi.mock('../wsl/wsl-host-connector', () => ({ createWslHostConnector: vi.fn() }))
|
||||
vi.mock('../wsl/wsl-host-e2e-fake-connector', () => ({
|
||||
createFakeWslHostConnector: vi.fn(),
|
||||
readFakeWslHostsConfig: () => null
|
||||
}))
|
||||
vi.mock('../orcad/orcad-artifact-materializer', () => ({ hasOrcadTemplate: () => true }))
|
||||
vi.mock('./runtime-environments/runtime-environment-removal-cleanup', () => ({
|
||||
retireRemovedRuntimeEnvironment: vi.fn()
|
||||
}))
|
||||
vi.mock('./runtime-environments/runtime-environment-status-probe', () => ({
|
||||
getRuntimeEnvironmentStatus: vi.fn()
|
||||
}))
|
||||
vi.mock('./runtime-environments/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: vi.fn()
|
||||
}))
|
||||
vi.mock('./runtime-environments/runtime-environment-request-connections', () => ({
|
||||
isRemoteRuntimeConnectionReady: () => false
|
||||
}))
|
||||
vi.mock('../ssh/orcad-terminal-census-client', () => ({ requestOrcadTerminalCensus: vi.fn() }))
|
||||
const service = vi.hoisted(() => ({
|
||||
add: vi.fn(
|
||||
async (): Promise<{
|
||||
ok: boolean
|
||||
environmentId?: string
|
||||
kind?: string
|
||||
message?: string
|
||||
}> => ({
|
||||
ok: true,
|
||||
environmentId: 'wsl:Ubuntu'
|
||||
})
|
||||
),
|
||||
remove: vi.fn(async () => ({ stopped: true })),
|
||||
recordForEnvironment: vi.fn(() => ({ distro: 'Ubuntu', environmentId: 'wsl:Ubuntu' }))
|
||||
}))
|
||||
vi.mock('../wsl/wsl-host-service-instance', () => ({
|
||||
getWslHostService: () => service,
|
||||
setWslHostService: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerWslHostHandlers } from './wsl-host-handlers'
|
||||
import { onWslHostUserAction, type WslHostUserAction } from '../wsl/wsl-host-user-actions'
|
||||
|
||||
function register(): void {
|
||||
registerWslHostHandlers({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the add and remove handlers run, and they read the active runtime setting alone.
|
||||
store: { getSettings: () => ({ activeRuntimeEnvironmentId: null }) } as never,
|
||||
getUserDataPath: () => '/user-data',
|
||||
invalidateTransport: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
describe('WSL host IPC: the user Add and Remove (D3a)', () => {
|
||||
// Removes are reported by the host service itself (see wsl-host-service.test.ts), so both the
|
||||
// sidebar and Settings paths count; here the service is mocked and only the Add is the IPC's.
|
||||
it('reports a successful Add, never a failed one', async () => {
|
||||
register()
|
||||
const heard: WslHostUserAction[] = []
|
||||
const stop = onWslHostUserAction((action) => heard.push(action))
|
||||
await handlers.get('wslHosts:remove')?.({}, { environmentId: 'wsl:Ubuntu' })
|
||||
await handlers.get('wslHosts:add')?.({}, { distro: 'Ubuntu' })
|
||||
service.add.mockResolvedValueOnce({ ok: false, kind: 'failed', message: 'no' })
|
||||
await handlers.get('wslHosts:add')?.({}, { distro: 'Debian' })
|
||||
stop()
|
||||
expect(heard).toEqual([{ kind: 'added', distro: 'Ubuntu' }])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { noteWslHostUserAction } from '../wsl/wsl-host-user-actions'
|
||||
import {
|
||||
WSL_HOST_STATUS_CHANNEL,
|
||||
type WslHostAddResult,
|
||||
@@ -218,8 +219,16 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void {
|
||||
ipcMain.handle('wslHosts:listDistros', () => wsl.listDistros())
|
||||
ipcMain.handle(
|
||||
'wslHosts:add',
|
||||
(_event, request: { distro: string; startIfStopped?: boolean }): Promise<WslHostAddResult> =>
|
||||
wsl.add(request)
|
||||
async (
|
||||
_event,
|
||||
request: { distro: string; startIfStopped?: boolean }
|
||||
): Promise<WslHostAddResult> => {
|
||||
const result = await wsl.add(request)
|
||||
if (result.ok) {
|
||||
noteWslHostUserAction({ kind: 'added', distro: request.distro })
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
ipcMain.handle('wslHosts:remove', async (_event, request: { environmentId: string }) => {
|
||||
if (args.store.getSettings().activeRuntimeEnvironmentId === request.environmentId) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate'
|
||||
|
||||
type Handler = (event: unknown, request?: unknown) => unknown
|
||||
|
||||
const handlers = vi.hoisted(() => new Map<string, Handler>())
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
removeHandler: (channel: string) => handlers.delete(channel),
|
||||
handle: (channel: string, handler: Handler) => handlers.set(channel, handler)
|
||||
}
|
||||
}))
|
||||
const driver = vi.hoisted(() => ({
|
||||
previewWslMove: vi.fn(async () => ({ outcome: 'ready' })),
|
||||
moveProjectsToWslHost: vi.fn(async () => ({ outcome: 'moved' })),
|
||||
cancelWslMove: vi.fn(() => true)
|
||||
}))
|
||||
vi.mock('../persistence/moving-wsl-projects/wsl-move-driver', () => driver)
|
||||
const track = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../telemetry/client', () => ({ track }))
|
||||
|
||||
import { registerWslMoveHandlers } from './wsl-move-handlers'
|
||||
|
||||
const MAIN_WINDOW_ID = 7
|
||||
const progress = vi.fn()
|
||||
const deps = vi.fn(() => ({ now: () => new Date(0), progress }))
|
||||
const invoke = (channel: string, request?: unknown, senderId = MAIN_WINDOW_ID) =>
|
||||
handlers.get(channel)?.({ sender: { id: senderId } }, request)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
deps.mockClear()
|
||||
track.mockClear()
|
||||
for (const fn of Object.values(driver)) {
|
||||
fn.mockClear()
|
||||
}
|
||||
registerWslMoveHandlers(
|
||||
() => '/unused',
|
||||
(senderId) => senderId === MAIN_WINDOW_ID,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The driver is mocked; the handler only passes deps through.
|
||||
deps as never
|
||||
)
|
||||
})
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('WSL move driver IPC', () => {
|
||||
it('previews, starts and cancels a move from the main window', async () => {
|
||||
expect(await invoke('wslMove:preview', { distro: 'Ubuntu', repoIds: ['r1'] })).toEqual({
|
||||
outcome: 'ready'
|
||||
})
|
||||
expect(await invoke('wslMove:start', { distro: 'Ubuntu' })).toEqual({ outcome: 'moved' })
|
||||
expect(invoke('wslMove:cancel', 'Ubuntu')).toBe(true)
|
||||
expect(driver.moveProjectsToWslHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ now: expect.any(Function) }),
|
||||
{ distro: 'Ubuntu' }
|
||||
)
|
||||
// The move's start and outcome are reported.
|
||||
expect(track.mock.calls.map(([, props]) => props.outcome)).toEqual(['started', 'finished'])
|
||||
})
|
||||
|
||||
it('is unreachable with the internal WSL hosts gate closed: nothing is built or run', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
|
||||
const refused = { outcome: 'refused', code: 'wsl_move_unavailable' }
|
||||
expect(await invoke('wslMove:preview', { distro: 'Ubuntu' })).toEqual(refused)
|
||||
expect(await invoke('wslMove:start', { distro: 'Ubuntu' })).toEqual(refused)
|
||||
expect(invoke('wslMove:cancel', 'Ubuntu')).toBe(false)
|
||||
expect(deps).not.toHaveBeenCalled()
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled()
|
||||
expect(driver.cancelWslMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses another window, and a malformed request', async () => {
|
||||
expect(await invoke('wslMove:start', { distro: 'Ubuntu' }, 99)).toMatchObject({
|
||||
code: 'wsl_move_unavailable',
|
||||
detail: 'not-main-window'
|
||||
})
|
||||
for (const bad of [
|
||||
undefined,
|
||||
'Ubuntu',
|
||||
{ distro: '' },
|
||||
{ distro: '..\\x' },
|
||||
{ distro: 'U', repoIds: [1] }
|
||||
]) {
|
||||
expect(await invoke('wslMove:start', bad)).toMatchObject({ detail: 'invalid-request' })
|
||||
}
|
||||
expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* The WSL move driver's production ports: the profile store, the runtime's live terminals, the hook
|
||||
* server's agent status, the daemon, the window and the distro's WSL host. Built per call, and only
|
||||
* after the internal WSL hosts gate is checked.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { app } from 'electron'
|
||||
import { encodePairingOffer } from '../../shared/pairing'
|
||||
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
|
||||
import { resolveEnvironment } from '../../shared/runtime-environment-store'
|
||||
import { findWslHostDeployment } from '../../shared/runtime-environment-wsl-host-store'
|
||||
import {
|
||||
WSL_MOVE_PROGRESS_CHANNEL,
|
||||
type WslMoveProgressEvent
|
||||
} from '../../shared/wsl-move/wsl-move-driver-types'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { getDaemonHistoryDir } from '../daemon/daemon-launch-paths'
|
||||
import {
|
||||
countInProcessFallbackTerminals,
|
||||
listLiveDaemonSessions
|
||||
} from '../daemon/daemon-provider-state'
|
||||
import { HistoryReader } from '../daemon/history-reader'
|
||||
import { orcadMigrationDestinationFor } from '../managed-server/lifecycle/orcad-runtime-conversion-wiring'
|
||||
import type { Store } from '../persistence'
|
||||
import { exportWslMoveManifest } from '../persistence/moving-wsl-projects/wsl-move-manifest-export'
|
||||
import { runGatedWslMoveDesktopRewritePass } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite'
|
||||
import type {
|
||||
WslMoveDriverDeps,
|
||||
WslMoveHost
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-driver-ports'
|
||||
import { proveWslMoveTerminalsExited } from '../persistence/moving-wsl-projects/wsl-move-exit-proof'
|
||||
import {
|
||||
collectWslMoveGateInputs,
|
||||
listWslMovePanes,
|
||||
type WslMoveGateFactSources
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-gate-facts'
|
||||
import {
|
||||
classifyWslGuestPaneForeground,
|
||||
readWslGuestPaneShellCwd
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-guest-foreground'
|
||||
import {
|
||||
listWslMoveSleptSessionPanes,
|
||||
readWslMoveSleptScrollbackFromHistory,
|
||||
withSleptSessionPanes,
|
||||
wslMoveTabTitle,
|
||||
wslMoveSleptSpoolRoot
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-slept-scrollback'
|
||||
import { scheduleWslMoveRewriteRetry } from '../persistence/moving-wsl-projects/wsl-move-rewrite-retry'
|
||||
import {
|
||||
captureWslMoveRestoreHints,
|
||||
type WslMoveRestoreHintPane
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-restore-hints'
|
||||
import {
|
||||
isWslMoveProjectFenced,
|
||||
wslMoveProjectRefForWorkspace
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { buildWslMoveShellHistoryBlobs } from '../persistence/moving-wsl-projects/wsl-move-shell-history-blobs'
|
||||
import { inspectPtyProviderProcessForRenderer } from '../providers/pty-process-inspection'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { getTrustedUIRendererWebContents, getTrustedUIRendererWindow } from './ui'
|
||||
import { registerWslMoveHandlers } from './wsl-move-handlers'
|
||||
import { registerWslMoveOrchestrator } from './wsl-move-orchestrator-wiring'
|
||||
import { createWslMoveUndoPorts } from './wsl-move-undo-wiring'
|
||||
import { connectWslMoveHostService, type WslMoveHostConnectMode } from './wsl-move-host-connect'
|
||||
import { wslMoveReloadableWindow } from './wsl-move-reloadable-window'
|
||||
import { setWslMoveReloadableWindow } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold'
|
||||
import {
|
||||
commitRemoteOrcadMigrationCatalog,
|
||||
listRemoteOrcadRepositories,
|
||||
stageRemoteOrcadMigrationBlobChunk
|
||||
} from '../ssh/orcad-migration-catalog-client'
|
||||
import { listRemoteOrcadFolderCatalog } from '../ssh/orcad-server-folder-workspaces'
|
||||
import { makeTerminalScrollbackSnapshotRef } from '../terminal-scrollback-snapshots'
|
||||
import { pauseWorkspacesForProjectMove } from '../window/project-move-pause'
|
||||
import { getWslHostService } from '../wsl/wsl-host-service-instance'
|
||||
import { readWslGuestProcessTable } from '../wsl/wsl-guest-process-table'
|
||||
import { isWslDistroRegistered } from '../wsl/wsl-distro-registry-id'
|
||||
import { defaultWslOrcadEnvironmentId } from '../wsl/wsl-orcad-deploy'
|
||||
import { ptyPaneKey } from './pty/pane/key-state'
|
||||
import { tryGetProviderForPty } from './pty/provider/registry'
|
||||
import { getRuntimeEnvironmentStatus } from './runtime-environments/runtime-environment-status-probe'
|
||||
/** The move's IPC handlers; its driver reads every move dependency from this module. */
|
||||
export function registerWslMoveWiring(store: Store, runtime: OrcaRuntimeService): void {
|
||||
setWslMoveReloadableWindow(() => {
|
||||
const contents = getTrustedUIRendererWebContents()
|
||||
return contents && wslMoveReloadableWindow(contents)
|
||||
})
|
||||
registerWslMoveHandlers(
|
||||
() => app.getPath('userData'),
|
||||
(senderId) => getTrustedUIRendererWebContents()?.id === senderId,
|
||||
() => createWslMoveDriverDeps(store, runtime, 'user'),
|
||||
() => createWslMoveUndoPorts(store, app.getPath('userData'))
|
||||
)
|
||||
registerWslMoveOrchestrator(store, () => createWslMoveDriverDeps(store, runtime, 'automatic'))
|
||||
}
|
||||
|
||||
/** Each live bridge pane's shell folder, one process read per distro. */
|
||||
async function readWslMovePaneShellCwds(
|
||||
panes: readonly WslMoveRestoreHintPane[]
|
||||
): Promise<Map<string, string>> {
|
||||
const cwds = new Map<string, string>()
|
||||
const distros = new Set(panes.flatMap((pane) => (pane.wslDistro ? [pane.wslDistro] : [])))
|
||||
for (const distro of distros) {
|
||||
const rows = await readWslGuestProcessTable(distro)
|
||||
for (const pane of panes) {
|
||||
const cwd =
|
||||
rows && pane.wslDistro === distro ? readWslGuestPaneShellCwd(rows, pane.paneKey) : null
|
||||
if (cwd) {
|
||||
cwds.set(pane.paneKey, cwd)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cwds
|
||||
}
|
||||
|
||||
export function createWslMoveDriverDeps(
|
||||
store: Store,
|
||||
runtime: OrcaRuntimeService,
|
||||
hostConnect: WslMoveHostConnectMode
|
||||
): WslMoveDriverDeps {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const facts: WslMoveGateFactSources = {
|
||||
listTerminals: (workspaceIds) => runtime.listWslMoveLiveTerminals(workspaceIds),
|
||||
paneKeyForPty: (ptyId) => ptyPaneKey.get(ptyId) ?? runtime.wslMovePaneKeyForPty(ptyId),
|
||||
inspect: async (ptyId) => {
|
||||
const provider = tryGetProviderForPty(ptyId)
|
||||
if (!provider) {
|
||||
throw new Error('terminal_gone')
|
||||
}
|
||||
// After a relaunch, a pane the window has not reopened is still live in the daemon.
|
||||
return inspectPtyProviderProcessForRenderer(provider, ptyId, {
|
||||
scanChildProcesses: true,
|
||||
includeUnattached: true
|
||||
})
|
||||
},
|
||||
agentRows: (paneKey) => agentHookServer.getStatusSnapshotForPane(paneKey),
|
||||
...(process.platform === 'win32'
|
||||
? {
|
||||
guestForegrounds: async (distro: string, paneKeys: readonly string[]) => {
|
||||
const rows = await readWslGuestProcessTable(distro)
|
||||
if (!rows) {
|
||||
console.warn('[wsl-move] Could not list processes in', distro)
|
||||
return new Map()
|
||||
}
|
||||
return new Map(paneKeys.map((key) => [key, classifyWslGuestPaneForeground(rows, key)]))
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
scrollbackBytes: async (tabId, leafId) =>
|
||||
(await store.statTerminalScrollbackSnapshotSize(
|
||||
makeTerminalScrollbackSnapshotRef(tabId, leafId)
|
||||
)) ?? 0,
|
||||
automationRuns: () => store.listAutomationRuns(),
|
||||
dispatches: () =>
|
||||
(runtime.getExistingOrchestrationDb()?.listLegacyWorkerTerminalRecoveryRows() ?? []).map(
|
||||
(row) => ({
|
||||
dispatchId: row.dispatch_id,
|
||||
worktreeId: row.worktree_id,
|
||||
state: row.worker_state
|
||||
})
|
||||
)
|
||||
}
|
||||
return {
|
||||
userDataPath,
|
||||
readCatalog: () => ({
|
||||
repos: store.getRepos(),
|
||||
folderWorkspaces: store.getFolderWorkspaces(),
|
||||
projectGroups: store.getProjectGroups()
|
||||
}),
|
||||
connectHost: (distro) => connectWslMoveHost(userDataPath, distro, hostConnect),
|
||||
isDistroRegistered: (distro, distroGuid) => isWslDistroRegistered(distro, distroGuid),
|
||||
exportManifest: (request) => exportWslMoveManifest(store, request),
|
||||
gateInputs: (scope) => collectWslMoveGateInputs(facts, scope),
|
||||
listPanes: (scope) => listWslMovePanes(facts, scope),
|
||||
...(process.platform === 'win32' ? { shellCwds: readWslMovePaneShellCwds } : {}),
|
||||
sleptSessionPanes: (workspaceIds, reported) =>
|
||||
listWslMoveSleptSessionPanes(store.getWorkspaceSession(), workspaceIds, reported),
|
||||
quiesce: {
|
||||
sleepWorkspaces: async (workspaceIds) => {
|
||||
const window = getTrustedUIRendererWindow()
|
||||
if (!window) {
|
||||
throw new Error('renderer_unavailable')
|
||||
}
|
||||
// Main's pause barrier: keeps browser tabs and waits for the window's session to persist.
|
||||
const outcomes = await pauseWorkspacesForProjectMove(window, runtime, workspaceIds)
|
||||
return {
|
||||
failedWorktreeIds: outcomes
|
||||
.filter((outcome) => outcome.failure || outcome.chatFailed || outcome.vmSuspendFailure)
|
||||
.map((outcome) => outcome.worktreeId)
|
||||
}
|
||||
},
|
||||
livePtyIds: (workspaceIds) =>
|
||||
runtime.listWslMoveLiveTerminals(workspaceIds).map((terminal) => terminal.ptyId),
|
||||
captureRestoreHints: (panes, distro) =>
|
||||
captureWslMoveRestoreHints(new HistoryReader(getDaemonHistoryDir()), panes, distro),
|
||||
proveExited: async (scope) => {
|
||||
const fallback = await countInProcessFallbackTerminals()
|
||||
return proveWslMoveTerminalsExited(
|
||||
{
|
||||
listSessions: listLiveDaemonSessions,
|
||||
countFallbackSessions: () => fallback,
|
||||
isScopeFenced: () =>
|
||||
[...scope.workspaceKeys].every((key) =>
|
||||
isWslMoveProjectFenced(store, wslMoveProjectRefForWorkspace(key))
|
||||
)
|
||||
},
|
||||
scope
|
||||
)
|
||||
}
|
||||
},
|
||||
snapshots: store,
|
||||
sleptScrollback: (panes, distro, migrationId, workspaceIds, reported) =>
|
||||
readWslMoveSleptScrollbackFromHistory(
|
||||
new HistoryReader(getDaemonHistoryDir()),
|
||||
withSleptSessionPanes(
|
||||
panes,
|
||||
listWslMoveSleptSessionPanes(store.getWorkspaceSession(), workspaceIds, reported)
|
||||
),
|
||||
distro,
|
||||
wslMoveSleptSpoolRoot(userDataPath, migrationId),
|
||||
(tabId) => wslMoveTabTitle(store.getWorkspaceSession(), tabId)
|
||||
),
|
||||
shellHistory: (distro, worktrees) => buildWslMoveShellHistoryBlobs({ distro, worktrees }),
|
||||
// The pass reloads the window it held at commit (wsl-move-window-write-hold.ts).
|
||||
finishOnDesktop: async () => {
|
||||
await runGatedWslMoveDesktopRewritePass({ userDataPath })
|
||||
// A step the host could not take yet (automations back on) retries in this run too.
|
||||
scheduleWslMoveRewriteRetry(userDataPath)
|
||||
},
|
||||
progress: (event: WslMoveProgressEvent) => {
|
||||
const window = getTrustedUIRendererWindow()
|
||||
if (window && !window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.send(WSL_MOVE_PROGRESS_CHANNEL, event)
|
||||
}
|
||||
},
|
||||
newMigrationId: () => randomUUID(),
|
||||
now: () => new Date()
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWslMoveHost(
|
||||
userDataPath: string,
|
||||
distro: string,
|
||||
mode: WslMoveHostConnectMode
|
||||
): Promise<WslMoveHost | null> {
|
||||
const service = getWslHostService()
|
||||
const environmentId = defaultWslOrcadEnvironmentId(distro)
|
||||
if (!service || !(await connectWslMoveHostService(service, userDataPath, distro, mode))) {
|
||||
return null
|
||||
}
|
||||
const environment = resolveEnvironment(userDataPath, environmentId)
|
||||
const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId)
|
||||
if (!status.ok) {
|
||||
return null
|
||||
}
|
||||
const pairingCode = encodePairingOffer(getPreferredPairingOffer(environment))
|
||||
const options = environment.runtimeId ? { expectedRuntimeId: environment.runtimeId } : {}
|
||||
const distroGuid = findWslHostDeployment(userDataPath, environmentId)?.distroId ?? undefined
|
||||
return {
|
||||
environmentId,
|
||||
name: environment.name,
|
||||
...(distroGuid ? { distroGuid } : {}),
|
||||
capabilities: status.result.capabilities ?? [],
|
||||
catalog: orcadMigrationDestinationFor(environment),
|
||||
commitWithBlobs: (manifest, expectedBlobs) =>
|
||||
commitRemoteOrcadMigrationCatalog(pairingCode, manifest, { ...options, expectedBlobs }),
|
||||
blobs: {
|
||||
stageChunk: (request) => stageRemoteOrcadMigrationBlobChunk(pairingCode, request, options)
|
||||
},
|
||||
readHolding: async () => {
|
||||
const [repositories, folders] = await Promise.all([
|
||||
listRemoteOrcadRepositories(pairingCode, options),
|
||||
listRemoteOrcadFolderCatalog(pairingCode, options)
|
||||
])
|
||||
return {
|
||||
repoIds: repositories.map((repo) => repo.id),
|
||||
folderWorkspaceIds: folders.folderWorkspaces.map((folder) => folder.id),
|
||||
// The host keeps no per-distro generation it can report yet.
|
||||
highestGeneration: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,14 @@ import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate
|
||||
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import {
|
||||
loadWslMoveFence,
|
||||
resetWslMoveFenceForTests
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import {
|
||||
isWslMoveWindowWriteHeld,
|
||||
setWslMoveReloadableWindow
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-window-write-hold'
|
||||
import {
|
||||
registerWslMoveDesktopRewriteStore,
|
||||
resetWslMoveDesktopRewriteStoresForTests
|
||||
@@ -29,15 +37,25 @@ beforeEach(() => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
registerWslMoveDesktopRewriteStore({ id: 'renderer-a', side: 'renderer' })
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
registerWslMoveHandlers(() => userDataPath)
|
||||
flushStorageData.mockReset()
|
||||
registerWslMoveHandlers(
|
||||
() => userDataPath,
|
||||
(senderId) => senderId === MAIN_WINDOW_ID
|
||||
)
|
||||
loadWslMoveFence(userDataPath)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
resetWslMoveDesktopRewriteStoresForTests()
|
||||
resetWslMoveFenceForTests()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const invoke = (channel: string, request?: unknown) => handlers.get(channel)?.({}, request)
|
||||
const MAIN_WINDOW_ID = 7
|
||||
const flushStorageData = vi.fn()
|
||||
const sender = (id: number) => ({ id, session: { flushStorageData } })
|
||||
const invoke = (channel: string, request?: unknown, senderId = MAIN_WINDOW_ID) =>
|
||||
handlers.get(channel)?.({ sender: sender(senderId) }, request)
|
||||
|
||||
describe('WSL move IPC', () => {
|
||||
it('lists pending renderer rewrites and records one', () => {
|
||||
@@ -47,9 +65,9 @@ describe('WSL move IPC', () => {
|
||||
expect(
|
||||
invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' })
|
||||
).toEqual({ recorded: true })
|
||||
// One renderer acknowledgment does not complete the fixture's required rewrite stores.
|
||||
// Its only registered store is now recorded, so the move finishes its rewrite.
|
||||
expect(listWslMoveCutovers(userDataPath)[0]).toMatchObject({
|
||||
phase: 'destination-committed',
|
||||
phase: 'client-rewritten',
|
||||
rewrittenStores: ['renderer-a']
|
||||
})
|
||||
})
|
||||
@@ -76,4 +94,154 @@ describe('WSL move IPC', () => {
|
||||
).toEqual({ recorded: false, reason: 'gated-off' })
|
||||
expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([])
|
||||
})
|
||||
|
||||
it('writes the window storage to disk before the ledger entry', () => {
|
||||
flushStorageData.mockImplementation(() => {
|
||||
expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([])
|
||||
})
|
||||
invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' })
|
||||
expect(flushStorageData).toHaveBeenCalledOnce()
|
||||
expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual(['renderer-a'])
|
||||
})
|
||||
|
||||
it('records only for the main window, whose storage holds the renderer stores', () => {
|
||||
expect(
|
||||
invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' }, 8)
|
||||
).toEqual({ recorded: false, reason: 'not-main-window' })
|
||||
expect(flushStorageData).not.toHaveBeenCalled()
|
||||
expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([])
|
||||
})
|
||||
|
||||
it('brings a moved project back, validating the renderer input', async () => {
|
||||
for (const bad of [undefined, null, { kind: 'repo' }, { kind: 'x', id: 'repo-1' }]) {
|
||||
expect(await invoke('wslMove:useProjectHereAgain', bad)).toEqual({
|
||||
released: false,
|
||||
reason: 'not-moved'
|
||||
})
|
||||
}
|
||||
expect(listWslMoveCutovers(userDataPath)).toHaveLength(1)
|
||||
|
||||
const page = { loaded: () => {} }
|
||||
const mainWindow = {
|
||||
id: MAIN_WINDOW_ID,
|
||||
isDestroyed: () => false,
|
||||
reload: vi.fn(),
|
||||
allowUnload: () => () => {},
|
||||
onLoadedOrGone: (listener: () => void) => {
|
||||
page.loaded = listener
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
setWslMoveReloadableWindow(() => mainWindow)
|
||||
const result = await handlers.get('wslMove:useProjectHereAgain')?.(
|
||||
{ sender: mainWindow },
|
||||
{ kind: 'repo', id: 'repo-1' }
|
||||
)
|
||||
expect(result).toMatchObject({ released: true })
|
||||
expect(listWslMoveCutovers(userDataPath)).toEqual([])
|
||||
// The page held only chats: its saves are dropped until the reloaded page has loaded.
|
||||
expect(isWslMoveWindowWriteHeld()).toBe(true)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(mainWindow.reload).toHaveBeenCalledOnce()
|
||||
page.loaded()
|
||||
expect(isWslMoveWindowWriteHeld()).toBe(false)
|
||||
setWslMoveReloadableWindow(() => null)
|
||||
})
|
||||
|
||||
// "p5 moved after this project" refused the undo after killing p1's terminals.
|
||||
it('refuses a superseded undo before it stops anything on the host', async () => {
|
||||
const rewritten = { rewrittenStores: ['renderer-a'] }
|
||||
invoke('wslMove:recordStoreRewritten', { migrationId: 'move-1', storeId: 'renderer-a' })
|
||||
seedWslMoveCutover(userDataPath, { ...cutover('move-1', 'retained'), ...rewritten })
|
||||
seedWslMoveCutover(userDataPath, {
|
||||
...cutover(
|
||||
'move-2',
|
||||
'retained',
|
||||
{ supersedesMigrationId: 'move-1' },
|
||||
{ repoIds: ['repo-5'] }
|
||||
),
|
||||
...rewritten
|
||||
})
|
||||
loadWslMoveFence(userDataPath)
|
||||
const stopHostCopies = vi.fn(async () => 'stopped' as const)
|
||||
registerWslMoveHandlers(
|
||||
() => userDataPath,
|
||||
(senderId) => senderId === MAIN_WINDOW_ID,
|
||||
undefined,
|
||||
() => ({ stopHostCopies })
|
||||
)
|
||||
expect(await invoke('wslMove:useProjectHereAgain', { kind: 'repo', id: 'repo-1' })).toEqual({
|
||||
released: false,
|
||||
reason: 'superseded',
|
||||
newerMove: { repoIds: ['repo-5'], folderWorkspaceIds: [] }
|
||||
})
|
||||
expect(stopHostCopies).not.toHaveBeenCalled()
|
||||
expect(listWslMoveCutovers(userDataPath)).toHaveLength(2)
|
||||
})
|
||||
|
||||
// P6's unsaved README blocked bringing p9 back.
|
||||
it("refuses only for unsaved files in the move's own workspaces, before stopping the host", async () => {
|
||||
const stopHostCopies = vi.fn(async () => 'stopped' as const)
|
||||
registerWslMoveHandlers(
|
||||
() => userDataPath,
|
||||
(senderId) => senderId === MAIN_WINDOW_ID,
|
||||
undefined,
|
||||
() => ({ stopHostCopies })
|
||||
)
|
||||
const request = (unsavedWorkspaceIds: unknown) => ({
|
||||
kind: 'repo',
|
||||
id: 'repo-1',
|
||||
unsavedWorkspaceIds
|
||||
})
|
||||
expect(
|
||||
await invoke('wslMove:useProjectHereAgain', request(['repo-2::/w', 'repo-1::/w', 7]))
|
||||
).toEqual({ released: false, reason: 'unsaved-files', unsavedWorkspaceIds: ['repo-1::/w'] })
|
||||
expect(stopHostCopies).not.toHaveBeenCalled()
|
||||
expect(listWslMoveCutovers(userDataPath)).toHaveLength(1)
|
||||
|
||||
setWslMoveReloadableWindow(() => null)
|
||||
expect(await invoke('wslMove:useProjectHereAgain', request(['repo-2::/w']))).toMatchObject({
|
||||
released: true
|
||||
})
|
||||
expect(stopHostCopies).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('brings a project back only for the main window, and only with the gate open', async () => {
|
||||
expect(await invoke('wslMove:useProjectHereAgain', { kind: 'repo', id: 'repo-1' }, 8)).toEqual({
|
||||
released: false,
|
||||
reason: 'failed',
|
||||
detail: 'not-main-window'
|
||||
})
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
|
||||
// Gate first: even malformed input from a non-main window gets the gated answer.
|
||||
expect(await handlers.get('wslMove:useProjectHereAgain')?.({}, null)).toEqual({
|
||||
released: false,
|
||||
reason: 'gated-off'
|
||||
})
|
||||
expect(listWslMoveCutovers(userDataPath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The host's copy of the agent kept running beside the one resumed here.
|
||||
it.each([
|
||||
['unreachable', 'host-unreachable'],
|
||||
['still-running', 'host-still-running']
|
||||
] as const)(
|
||||
'keeps the move when the host copies are %s, so no agent resumes here beside them',
|
||||
async (stop, reason) => {
|
||||
const stopHostCopies = vi.fn(async () => stop)
|
||||
registerWslMoveHandlers(
|
||||
() => userDataPath,
|
||||
(senderId) => senderId === MAIN_WINDOW_ID,
|
||||
undefined,
|
||||
() => ({ stopHostCopies })
|
||||
)
|
||||
expect(await invoke('wslMove:useProjectHereAgain', { kind: 'repo', id: 'repo-1' })).toEqual({
|
||||
released: false,
|
||||
reason
|
||||
})
|
||||
expect(stopHostCopies).toHaveBeenCalledOnce()
|
||||
expect(listWslMoveCutovers(userDataPath)).toHaveLength(1)
|
||||
expect(isWslMoveWindowWriteHeld()).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,19 +1,58 @@
|
||||
import { ipcMain } from 'electron'
|
||||
/**
|
||||
* IPC for a WSL move: the move itself (preview, start, cancel), the renderer half of a committed
|
||||
* move's desktop rewrite, and bringing a moved project back to this computer. Every channel answers
|
||||
* empty / refuses while the internal WSL hosts gate is closed, before it touches anything.
|
||||
*/
|
||||
import { ipcMain, type WebContents } from 'electron'
|
||||
import type {
|
||||
WslMovePendingRendererRewrite,
|
||||
WslMoveRecordStoreRewrittenResult
|
||||
} from '../../shared/wsl-move/wsl-move-renderer-rewrite-types'
|
||||
import type {
|
||||
WslMovePreview,
|
||||
WslMoveRefusal,
|
||||
WslMoveResult,
|
||||
WslMoveUnfinished
|
||||
} from '../../shared/wsl-move/wsl-move-driver-types'
|
||||
import { parseWslMoveRequest } from '../../shared/wsl-move/wsl-move-request'
|
||||
import type { WslMoveUseLocallyResult } from '../../shared/wsl-move/wsl-moved-project'
|
||||
import {
|
||||
listPendingWslMoveRendererRewrites,
|
||||
recordWslMoveRendererStoreRewritten
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite'
|
||||
import {
|
||||
cancelWslMove,
|
||||
moveProjectsToWslHost,
|
||||
previewWslMove
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-driver'
|
||||
import type { WslMoveDriverDeps } from '../persistence/moving-wsl-projects/wsl-move-driver-ports'
|
||||
import { listUnfinishedWslMoves } from '../persistence/moving-wsl-projects/wsl-move-host-automations-rewrite'
|
||||
import { runTrackedWslMove } from '../telemetry/wsl-move-telemetry'
|
||||
import { wslHostsGateOpen } from '../wsl/wsl-host-gate'
|
||||
import type { WslMoveUndoPorts } from './wsl-move-undo-wiring'
|
||||
import { releaseWslMovedProjectHere } from './wsl-move-release-here'
|
||||
|
||||
export const WSL_MOVE_HANDLER_CHANNELS = [
|
||||
'wslMove:listPendingRendererRewrites',
|
||||
'wslMove:recordStoreRewritten'
|
||||
'wslMove:recordStoreRewritten',
|
||||
'wslMove:preview',
|
||||
'wslMove:start',
|
||||
'wslMove:cancel',
|
||||
'wslMove:listUnfinished',
|
||||
'wslMove:useProjectHereAgain'
|
||||
] as const
|
||||
|
||||
export function registerWslMoveHandlers(getUserDataPath: () => string): void {
|
||||
type RendererEvent = { sender?: Pick<WebContents, 'id' | 'session'> }
|
||||
|
||||
export function registerWslMoveHandlers(
|
||||
getUserDataPath: () => string,
|
||||
/** The renderer stores live in the main window's storage; another window's copy is not them. */
|
||||
isMainWindowRenderer: (senderId: number) => boolean,
|
||||
/** Built per call, only once the gate is open. */
|
||||
driverDeps?: () => WslMoveDriverDeps,
|
||||
/** Undo's reach into the host; absent, there is no host to stop. */
|
||||
undoPorts: () => WslMoveUndoPorts = () => ({ stopHostCopies: async () => 'stopped' })
|
||||
): void {
|
||||
for (const channel of WSL_MOVE_HANDLER_CHANNELS) {
|
||||
ipcMain.removeHandler(channel)
|
||||
}
|
||||
@@ -22,7 +61,8 @@ export function registerWslMoveHandlers(getUserDataPath: () => string): void {
|
||||
)
|
||||
ipcMain.handle(
|
||||
'wslMove:recordStoreRewritten',
|
||||
(_event, request: unknown): WslMoveRecordStoreRewrittenResult => {
|
||||
(event: RendererEvent, request: unknown): WslMoveRecordStoreRewrittenResult => {
|
||||
// Renderer input: anything but two strings is an unknown store.
|
||||
if (
|
||||
typeof request !== 'object' ||
|
||||
request === null ||
|
||||
@@ -33,10 +73,108 @@ export function registerWslMoveHandlers(getUserDataPath: () => string): void {
|
||||
) {
|
||||
return { recorded: false, reason: 'unknown-store' }
|
||||
}
|
||||
return recordWslMoveRendererStoreRewritten(getUserDataPath(), {
|
||||
migrationId: request.migrationId,
|
||||
storeId: request.storeId
|
||||
})
|
||||
const sender = event.sender
|
||||
if (!sender || !isMainWindowRenderer(sender.id)) {
|
||||
return { recorded: false, reason: 'not-main-window' }
|
||||
}
|
||||
return recordWslMoveRendererStoreRewritten(
|
||||
getUserDataPath(),
|
||||
{ migrationId: request.migrationId, storeId: request.storeId },
|
||||
() => sender.session.flushStorageData()
|
||||
)
|
||||
}
|
||||
)
|
||||
registerWslMoveDriverHandlers(getUserDataPath, isMainWindowRenderer, driverDeps)
|
||||
registerWslMoveUseHereHandler(getUserDataPath, isMainWindowRenderer, undoPorts)
|
||||
}
|
||||
|
||||
function registerWslMoveDriverHandlers(
|
||||
getUserDataPath: () => string,
|
||||
isMainWindowRenderer: (senderId: number) => boolean,
|
||||
driverDeps: (() => WslMoveDriverDeps) | undefined
|
||||
): void {
|
||||
// Gate first: with it closed, no dependency is built and nothing is read.
|
||||
const refuseUnlessOpen = (event: RendererEvent): WslMoveRefusal | null => {
|
||||
if (!wslHostsGateOpen() || !driverDeps) {
|
||||
return { outcome: 'refused', code: 'wsl_move_unavailable' }
|
||||
}
|
||||
const sender = event.sender
|
||||
return sender && isMainWindowRenderer(sender.id)
|
||||
? null
|
||||
: { outcome: 'refused', code: 'wsl_move_unavailable', detail: 'not-main-window' }
|
||||
}
|
||||
ipcMain.handle(
|
||||
'wslMove:preview',
|
||||
async (event: RendererEvent, request: unknown): Promise<WslMovePreview> => {
|
||||
const parsed = parseWslMoveRequest(request)
|
||||
return (
|
||||
refuseUnlessOpen(event) ??
|
||||
(parsed && driverDeps ? previewWslMove(driverDeps(), parsed) : invalidRequest())
|
||||
)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'wslMove:start',
|
||||
async (event: RendererEvent, request: unknown): Promise<WslMoveResult> => {
|
||||
const parsed = parseWslMoveRequest(request)
|
||||
return (
|
||||
refuseUnlessOpen(event) ??
|
||||
(parsed && driverDeps
|
||||
? runTrackedWslMove(driverDeps(), (deps) => moveProjectsToWslHost(deps, parsed))
|
||||
: invalidRequest())
|
||||
)
|
||||
}
|
||||
)
|
||||
ipcMain.handle('wslMove:listUnfinished', (): WslMoveUnfinished[] =>
|
||||
wslHostsGateOpen() ? listUnfinishedWslMoves(getUserDataPath()) : []
|
||||
)
|
||||
ipcMain.handle(
|
||||
'wslMove:cancel',
|
||||
(event: RendererEvent, distro: unknown): boolean =>
|
||||
refuseUnlessOpen(event) === null && typeof distro === 'string' && cancelWslMove(distro)
|
||||
)
|
||||
}
|
||||
|
||||
function invalidRequest(): WslMoveRefusal {
|
||||
return { outcome: 'refused', code: 'wsl_move_unavailable', detail: 'invalid-request' }
|
||||
}
|
||||
|
||||
function registerWslMoveUseHereHandler(
|
||||
getUserDataPath: () => string,
|
||||
isMainWindowRenderer: (senderId: number) => boolean,
|
||||
createUndoPorts: () => WslMoveUndoPorts
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
'wslMove:useProjectHereAgain',
|
||||
async (event: { sender?: WebContents }, request: unknown): Promise<WslMoveUseLocallyResult> => {
|
||||
// Gate first: with it closed, nothing is read.
|
||||
if (!wslHostsGateOpen()) {
|
||||
return { released: false, reason: 'gated-off' }
|
||||
}
|
||||
const sender = event.sender
|
||||
if (!sender || !isMainWindowRenderer(sender.id)) {
|
||||
return { released: false, reason: 'failed', detail: 'not-main-window' }
|
||||
}
|
||||
if (
|
||||
typeof request !== 'object' ||
|
||||
request === null ||
|
||||
!('kind' in request) ||
|
||||
!('id' in request) ||
|
||||
typeof request.id !== 'string' ||
|
||||
(request.kind !== 'repo' && request.kind !== 'folder')
|
||||
) {
|
||||
return { released: false, reason: 'not-moved' }
|
||||
}
|
||||
const ref =
|
||||
request.kind === 'repo' ? { repoId: request.id } : { folderWorkspaceId: request.id }
|
||||
return releaseWslMovedProjectHere(
|
||||
getUserDataPath(),
|
||||
ref,
|
||||
'unsavedWorkspaceIds' in request && Array.isArray(request.unsavedWorkspaceIds)
|
||||
? request.unsavedWorkspaceIds.filter((id): id is string => typeof id === 'string')
|
||||
: [],
|
||||
createUndoPorts
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WslHostStore from '../../shared/runtime-environment-wsl-host-store'
|
||||
import { WslHostService } from '../wsl/wsl-host-service'
|
||||
import { UBUNTU, makeConnector } from '../wsl/wsl-host-service-test-connector'
|
||||
import { connectWslMoveHostService } from './wsl-move-host-connect'
|
||||
|
||||
const removal = vi.hoisted(() => ({ pending: false }))
|
||||
vi.mock('../../shared/runtime-environment-wsl-host-store', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WslHostStore>()
|
||||
return {
|
||||
...actual,
|
||||
// E11 (#33843): `pendingStop` is optional until that PR lands, so the record gains it here.
|
||||
findWslHostDeployment: (...args: Parameters<typeof actual.findWslHostDeployment>) => {
|
||||
const record = actual.findWslHostDeployment(...args)
|
||||
return record && removal.pending ? { ...record, pendingStop: { reason: 'x' } } : record
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('the move driver reaching its WSL host', () => {
|
||||
let userDataPath: string
|
||||
|
||||
function hostService(connector: ReturnType<typeof makeConnector>): WslHostService {
|
||||
const service = new WslHostService({
|
||||
userDataPath: () => userDataPath,
|
||||
isEnabled: () => true,
|
||||
hasServerFiles: () => true,
|
||||
publish: () => {},
|
||||
pairingChanged: () => {},
|
||||
ready: () => {},
|
||||
removed: () => {}
|
||||
})
|
||||
service.attach(connector)
|
||||
return service
|
||||
}
|
||||
|
||||
async function stoppedAndParkedHost() {
|
||||
const connector = makeConnector([UBUNTU])
|
||||
const service = hostService(connector)
|
||||
await service.add({ distro: 'Ubuntu' })
|
||||
await connector.disconnect('Ubuntu')
|
||||
connector.distro.state = 'stopped'
|
||||
service.handleConnectionChange('Ubuntu', null, {
|
||||
state: 'distro-stopped',
|
||||
terminals: 'exited',
|
||||
recovering: false
|
||||
})
|
||||
// A poll finding the distro stopped parks the host.
|
||||
await expect(service.ensureForRuntimeCall('wsl:ubuntu')).rejects.toThrow('Ubuntu was stopped')
|
||||
connector.connect.mockClear()
|
||||
return { connector, service }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-move-host-connect-'))
|
||||
removal.pending = false
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("a user's move connects a parked host with no visible work (E9)", async () => {
|
||||
const { connector, service } = await stoppedAndParkedHost()
|
||||
expect(service.hasUserVisibleWork('Ubuntu')).toBe(false)
|
||||
await expect(connectWslMoveHostService(service, userDataPath, 'Ubuntu', 'user')).resolves.toBe(
|
||||
true
|
||||
)
|
||||
expect(connector.connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('an automatic move, and its startup recovery, never boots a stopped distro (A1)', async () => {
|
||||
const { connector, service } = await stoppedAndParkedHost()
|
||||
await expect(
|
||||
connectWslMoveHostService(service, userDataPath, 'Ubuntu', 'automatic')
|
||||
).rejects.toThrow('Ubuntu was stopped')
|
||||
// Running again is not enough while parked, even with visible work.
|
||||
connector.distro.state = 'running'
|
||||
service.setVisibleWork(['wsl:ubuntu'])
|
||||
await expect(
|
||||
connectWslMoveHostService(service, userDataPath, 'Ubuntu', 'automatic')
|
||||
).rejects.toThrow('Ubuntu was stopped')
|
||||
expect(connector.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never connects a host whose Remove is pending, even for the user', async () => {
|
||||
const { connector, service } = await stoppedAndParkedHost()
|
||||
removal.pending = true
|
||||
for (const mode of ['user', 'automatic'] as const) {
|
||||
await expect(connectWslMoveHostService(service, userDataPath, 'Ubuntu', mode)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
expect(connector.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves an already-connected host alone in either mode: no reconnect', async () => {
|
||||
const connector = makeConnector([UBUNTU])
|
||||
const service = hostService(connector)
|
||||
await service.add({ distro: 'Ubuntu' })
|
||||
connector.connect.mockClear()
|
||||
for (const mode of ['user', 'automatic'] as const) {
|
||||
await expect(connectWslMoveHostService(service, userDataPath, 'Ubuntu', mode)).resolves.toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
expect(connector.connect).not.toHaveBeenCalled()
|
||||
expect(connector.disconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* How the move driver reaches its WSL host. A move the user started (the dialog's preview or
|
||||
* start) may boot the distro and lifts a park; an automatic one (the orchestrator, including its
|
||||
* startup recovery) never boots it (A1, E9). Neither reconnects a host that is already serving,
|
||||
* and a host with a Remove still pending is never touched.
|
||||
*/
|
||||
import { findWslHostDeployment } from '../../shared/runtime-environment-wsl-host-store'
|
||||
import type { WslHostService } from '../wsl/wsl-host-service'
|
||||
import { defaultWslOrcadEnvironmentId } from '../wsl/wsl-orcad-deploy'
|
||||
|
||||
export type WslMoveHostConnectMode = 'user' | 'automatic'
|
||||
|
||||
/** E11 (#33843): a Remove that could not prove the server stopped leaves `pendingStop` set. */
|
||||
export function wslHostRemovalPending(userDataPath: string, distro: string): boolean {
|
||||
const record: object | null = findWslHostDeployment(
|
||||
userDataPath,
|
||||
defaultWslOrcadEnvironmentId(distro)
|
||||
)
|
||||
// Optional until #33843 lands: older records have no such field.
|
||||
return !!record && 'pendingStop' in record && !!record.pendingStop
|
||||
}
|
||||
|
||||
/** False when there is no host to use; throws when it would not connect. */
|
||||
export async function connectWslMoveHostService(
|
||||
service: Pick<
|
||||
WslHostService,
|
||||
'recordForEnvironment' | 'getStatuses' | 'connect' | 'ensureForRuntimeCall'
|
||||
>,
|
||||
userDataPath: string,
|
||||
distro: string,
|
||||
mode: WslMoveHostConnectMode
|
||||
): Promise<boolean> {
|
||||
const environmentId = defaultWslOrcadEnvironmentId(distro)
|
||||
if (!service.recordForEnvironment(environmentId) || wslHostRemovalPending(userDataPath, distro)) {
|
||||
return false
|
||||
}
|
||||
if (mode === 'automatic') {
|
||||
await service.ensureForRuntimeCall(environmentId)
|
||||
} else if (
|
||||
// connect() would bump the liveness generation and re-ensure a server that already serves.
|
||||
!service
|
||||
.getStatuses()
|
||||
.some((status) => status.environmentId === environmentId && status.state === 'ready')
|
||||
) {
|
||||
await service.connect(environmentId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* The move orchestrator's automatic path end to end over a real WSL host service: its own host
|
||||
* readiness (prepareWslMoveHost), then the driver's automatic connect (E9). A host that is
|
||||
* already serving is never reconnected, and a bridge user's host is added exactly once.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WslMoveResult } from '../../shared/wsl-move/wsl-move-driver-types'
|
||||
import { prepareWslMoveHost } from '../persistence/moving-wsl-projects/wsl-move-host-readiness'
|
||||
import { WslMoveOrchestrator } from '../persistence/moving-wsl-projects/wsl-move-orchestrator'
|
||||
import { WslHostService } from '../wsl/wsl-host-service'
|
||||
import { UBUNTU, makeConnector } from '../wsl/wsl-host-service-test-connector'
|
||||
import { connectWslMoveHostService, wslHostRemovalPending } from './wsl-move-host-connect'
|
||||
|
||||
const moved: WslMoveResult = {
|
||||
outcome: 'moved',
|
||||
migrationId: 'm1',
|
||||
phase: 'retained',
|
||||
reloadRequired: true
|
||||
}
|
||||
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-move-orchestrator-connect-'))
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** The orchestrator over a real host service, readying hosts as the wiring does. */
|
||||
function orchestratorOverHost(connector: ReturnType<typeof makeConnector>) {
|
||||
const service = new WslHostService({
|
||||
userDataPath: () => userDataPath,
|
||||
isEnabled: () => true,
|
||||
hasServerFiles: () => true,
|
||||
publish: () => {},
|
||||
pairingChanged: () => {},
|
||||
ready: () => {},
|
||||
removed: () => {}
|
||||
})
|
||||
service.attach(connector)
|
||||
const isHostReady = (distro: string): boolean =>
|
||||
service
|
||||
.getStatuses()
|
||||
.some(
|
||||
(status) => status.distro.toLowerCase() === distro.toLowerCase() && status.state === 'ready'
|
||||
)
|
||||
const environmentId = 'wsl:ubuntu'
|
||||
// The driver's own reach for the host, in the orchestrator's (automatic) mode.
|
||||
const driverConnect = vi.fn((distro: string) =>
|
||||
connectWslMoveHostService(service, userDataPath, distro, 'automatic')
|
||||
)
|
||||
const orchestrator = new WslMoveOrchestrator({
|
||||
now: () => 1_000,
|
||||
appVersion: '1.0.0',
|
||||
autoMoveEnabled: () => true,
|
||||
prepareHost: (distro, stillWanted) =>
|
||||
prepareWslMoveHost(
|
||||
{
|
||||
readRunningDistros: async () => ({ status: 'confirmed', distros: ['Ubuntu'] }),
|
||||
isHostReady,
|
||||
removalPending: () => wslHostRemovalPending(userDataPath, distro),
|
||||
hasHost: () => service.recordForEnvironment(environmentId) !== null,
|
||||
connect: () => service.connect(environmentId),
|
||||
add: () => service.add({ distro, startIfStopped: false })
|
||||
},
|
||||
distro,
|
||||
stillWanted
|
||||
),
|
||||
hostRemovalPending: (distro) => wslHostRemovalPending(userDataPath, distro),
|
||||
hasHost: () => service.recordForEnvironment(environmentId) !== null,
|
||||
isHostReady,
|
||||
preview: async (distro) => {
|
||||
await driverConnect(distro)
|
||||
return {
|
||||
outcome: 'ready',
|
||||
distro,
|
||||
repoIds: ['r1'],
|
||||
folderWorkspaceIds: [],
|
||||
changed: [],
|
||||
heldByHost: [],
|
||||
refused: [],
|
||||
blockers: []
|
||||
}
|
||||
},
|
||||
start: async (distro) => {
|
||||
await driverConnect(distro)
|
||||
return moved
|
||||
},
|
||||
finishedMove: () => null,
|
||||
checkMovedDistro: async () => 'present',
|
||||
releaseFinishedMove: async () => ({ released: false, reason: 'not-moved' }),
|
||||
load: () => new Map(),
|
||||
save: () => {},
|
||||
schedule: () => () => {},
|
||||
track: () => {},
|
||||
publish: () => {}
|
||||
})
|
||||
return { orchestrator, service, driverConnect }
|
||||
}
|
||||
|
||||
describe('the orchestrator readying a host, then the driver reaching it (E9)', () => {
|
||||
it('never reconnects a host that is already serving', async () => {
|
||||
const connector = makeConnector([UBUNTU])
|
||||
const { orchestrator, service, driverConnect } = orchestratorOverHost(connector)
|
||||
await service.add({ distro: 'Ubuntu' })
|
||||
connector.connect.mockClear()
|
||||
await orchestrator.trigger('Ubuntu', 'project-opened')
|
||||
expect(orchestrator.list()[0]?.state).toEqual({ kind: 'done', migrationId: 'm1' })
|
||||
// Preview and start each reached the host through the automatic path.
|
||||
expect(driverConnect).toHaveBeenCalledTimes(2)
|
||||
expect(connector.connect).not.toHaveBeenCalled()
|
||||
expect(connector.disconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("adds a bridge user's host once, and the driver's automatic reach adds no connect", async () => {
|
||||
const connector = makeConnector([UBUNTU])
|
||||
const { orchestrator, service } = orchestratorOverHost(connector)
|
||||
expect(service.recordForEnvironment('wsl:ubuntu')).toBeNull()
|
||||
await orchestrator.trigger('Ubuntu', 'project-opened')
|
||||
expect(service.recordForEnvironment('wsl:ubuntu')).not.toBeNull()
|
||||
expect(connector.connect).toHaveBeenCalledTimes(1)
|
||||
expect(orchestrator.list()[0]?.state).toEqual({ kind: 'done', migrationId: 'm1' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The window's slept-pane bindings for an automatic move (D3a): only the window knows them, and a
|
||||
* move without them misses those panes' history. Main asks; the window answers by request id.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { WslMoveRequest } from '../../shared/wsl-move/wsl-move-driver-types'
|
||||
import { WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL } from '../../shared/wsl-move/wsl-move-orchestrator-types'
|
||||
import { getTrustedUIRendererWindow } from './ui'
|
||||
|
||||
const SLEPT_PANES_TIMEOUT_MS = 5_000
|
||||
|
||||
type SleptPanes = NonNullable<WslMoveRequest['sleptPanes']>
|
||||
|
||||
const sleptPaneRequests = new Map<string, (panes: SleptPanes | null) => void>()
|
||||
|
||||
/** The window's slept-pane bindings, which only it knows; a move without them misses their history. */
|
||||
export function requestSleptPanes(): Promise<SleptPanes> {
|
||||
const window = getTrustedUIRendererWindow()
|
||||
if (!window || window.isDestroyed() || window.webContents.isDestroyed()) {
|
||||
return Promise.reject(new Error('wsl_move_window_unavailable'))
|
||||
}
|
||||
const requestId = randomUUID()
|
||||
return new Promise<SleptPanes>((resolve, reject) => {
|
||||
const timer = setTimeout(() => answer(null), SLEPT_PANES_TIMEOUT_MS)
|
||||
function answer(panes: SleptPanes | null): void {
|
||||
clearTimeout(timer)
|
||||
sleptPaneRequests.delete(requestId)
|
||||
if (panes) {
|
||||
resolve(panes)
|
||||
} else {
|
||||
reject(new Error('wsl_move_window_unavailable'))
|
||||
}
|
||||
}
|
||||
sleptPaneRequests.set(requestId, answer)
|
||||
window.webContents.send(WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL, { requestId })
|
||||
})
|
||||
}
|
||||
|
||||
function parseSleptPanes(value: unknown): SleptPanes | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
const panes: SleptPanes = []
|
||||
for (const entry of value) {
|
||||
if (
|
||||
typeof entry !== 'object' ||
|
||||
entry === null ||
|
||||
!('paneKey' in entry) ||
|
||||
!('sessionId' in entry) ||
|
||||
typeof entry.paneKey !== 'string' ||
|
||||
typeof entry.sessionId !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
panes.push({ paneKey: entry.paneKey, sessionId: entry.sessionId })
|
||||
}
|
||||
return panes
|
||||
}
|
||||
|
||||
/** The window's answer; an unparseable one fails the request rather than move without panes. */
|
||||
export function answerWslMoveSleptPanes(requestId: string, value: unknown): void {
|
||||
sleptPaneRequests.get(requestId)?.(parseSleptPanes(value))
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate'
|
||||
import { WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL } from '../../shared/wsl-move/wsl-move-orchestrator-types'
|
||||
|
||||
type Handler = (event: unknown, request?: unknown) => unknown
|
||||
|
||||
const handlers = vi.hoisted(() => new Map<string, Handler>())
|
||||
const sent = vi.hoisted((): { channel: string; payload: unknown }[] => [])
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/user-data', getVersion: () => '1.0.0' },
|
||||
ipcMain: {
|
||||
removeHandler: (channel: string) => handlers.delete(channel),
|
||||
handle: (channel: string, handler: Handler) => handlers.set(channel, handler)
|
||||
}
|
||||
}))
|
||||
const MAIN_WINDOW_ID = 7
|
||||
vi.mock('./ui', () => {
|
||||
const webContents = {
|
||||
id: MAIN_WINDOW_ID,
|
||||
isDestroyed: () => false,
|
||||
send: (channel: string, payload: unknown) => sent.push({ channel, payload })
|
||||
}
|
||||
return {
|
||||
getTrustedUIRendererWebContents: () => webContents,
|
||||
getTrustedUIRendererWindow: () => ({ isDestroyed: () => false, webContents })
|
||||
}
|
||||
})
|
||||
type Status = { distro: string; state: string; environmentId: string; detail: null }
|
||||
type RunningRead = { status: 'confirmed'; distros: string[] } | { status: 'unavailable' }
|
||||
const host = vi.hoisted(() => {
|
||||
const initial = (): RunningRead => ({ status: 'confirmed', distros: ['Ubuntu'] })
|
||||
return {
|
||||
statuses: new Array<Status>(),
|
||||
listeners: new Set<() => void>(),
|
||||
running: initial(),
|
||||
added: new Array<string>()
|
||||
}
|
||||
})
|
||||
const deployments = vi.hoisted(() => new Map<string, Record<string, unknown>>())
|
||||
vi.mock('../../shared/runtime-environment-wsl-host-store', () => ({
|
||||
findWslHostDeployment: (_userData: string, environmentId: string) =>
|
||||
deployments.get(environmentId) ?? null
|
||||
}))
|
||||
vi.mock('../wsl/wsl-distro-registry-id', () => ({ readWslDistroRegistrations: async () => null }))
|
||||
vi.mock('../wsl/wsl-orcad-deploy', () => ({
|
||||
defaultWslOrcadEnvironmentId: (distro: string) => `wsl:${distro}`
|
||||
}))
|
||||
vi.mock('../wsl-background-distro-gate', () => ({
|
||||
readConfirmedRunningWslDistros: async () => host.running
|
||||
}))
|
||||
vi.mock('../wsl/wsl-host-service-instance', () => ({
|
||||
getWslHostService: () => ({
|
||||
getStatuses: () => host.statuses,
|
||||
onStatusesChanged: (listener: () => void) => {
|
||||
host.listeners.add(listener)
|
||||
return () => host.listeners.delete(listener)
|
||||
},
|
||||
recordForEnvironment: () => null,
|
||||
connect: async () => {},
|
||||
// A bridge user's host: adding it makes it ready.
|
||||
add: async ({ distro }: { distro: string }) => {
|
||||
host.added.push(distro)
|
||||
host.statuses.push({ distro, state: 'ready', environmentId: `wsl:${distro}`, detail: null })
|
||||
return { ok: true, environmentId: `wsl:${distro}` }
|
||||
}
|
||||
})
|
||||
}))
|
||||
const driver = vi.hoisted(() => ({
|
||||
previewWslMove: vi.fn(async (_deps: unknown, request: { distro: string }) => ({
|
||||
outcome: 'ready',
|
||||
distro: request.distro,
|
||||
repoIds: ['r1'],
|
||||
folderWorkspaceIds: [],
|
||||
changed: [],
|
||||
heldByHost: [],
|
||||
refused: [],
|
||||
blockers: []
|
||||
})),
|
||||
moveProjectsToWslHost: vi.fn(async () => ({
|
||||
outcome: 'moved',
|
||||
migrationId: 'm1',
|
||||
phase: 'retained',
|
||||
reloadRequired: true
|
||||
}))
|
||||
}))
|
||||
vi.mock('../persistence/moving-wsl-projects/wsl-move-driver', () => driver)
|
||||
const journal = vi.hoisted(() => ({ unreadable: false }))
|
||||
vi.mock('../persistence/moving-wsl-projects/wsl-move-cutover-journal', () => ({
|
||||
listWslMoveCutoverChain: () => {
|
||||
if (journal.unreadable) {
|
||||
throw new Error('wsl_move_journal_unreadable')
|
||||
}
|
||||
return []
|
||||
}
|
||||
}))
|
||||
const store = vi.hoisted(() => ({ reads: 0, records: new Map<string, unknown>() }))
|
||||
vi.mock('../persistence/moving-wsl-projects/wsl-move-orchestrator-store', () => ({
|
||||
readWslMoveOrchestratorRecords: () => {
|
||||
store.reads++
|
||||
return store.records
|
||||
},
|
||||
writeWslMoveOrchestratorRecords: () => {}
|
||||
}))
|
||||
const hostActions = vi.hoisted(() => ({
|
||||
listeners: new Array<(action: { kind: 'added' | 'removed'; distro: string }) => void>()
|
||||
}))
|
||||
vi.mock('../wsl/wsl-host-user-actions', () => ({
|
||||
onWslHostUserAction: (
|
||||
listener: (action: { kind: 'added' | 'removed'; distro: string }) => void
|
||||
) => {
|
||||
hostActions.listeners.push(listener)
|
||||
return () => {}
|
||||
}
|
||||
}))
|
||||
const releases = vi.hoisted(() => ({ listeners: new Array<(distro: string) => void>() }))
|
||||
vi.mock('./wsl-move-release-here', () => ({
|
||||
releaseWslMoveChainHere: vi.fn(),
|
||||
onWslMoveReleasedByUser: (listener: (distro: string) => void) => {
|
||||
releases.listeners.push(listener)
|
||||
return () => {}
|
||||
}
|
||||
}))
|
||||
vi.mock('./wsl-move-undo-wiring', () => ({ createWslMoveUndoPorts: vi.fn() }))
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
|
||||
const sleptPanes = [{ paneKey: 'tab:leaf', sessionId: 'wt@@a' }]
|
||||
|
||||
function requestIdOf(payload: unknown): string {
|
||||
return typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
'requestId' in payload &&
|
||||
typeof payload.requestId === 'string'
|
||||
? payload.requestId
|
||||
: ''
|
||||
}
|
||||
|
||||
async function register(): Promise<void> {
|
||||
const { registerWslMoveOrchestrator } = await import('./wsl-move-orchestrator-wiring')
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The driver is mocked; the wiring only passes deps through.
|
||||
registerWslMoveOrchestrator({} as never, () => ({ now: () => new Date(0) }) as never)
|
||||
}
|
||||
|
||||
/** Answers every slept-pane request the window has been sent. */
|
||||
async function answerWindow(): Promise<void> {
|
||||
for (let turn = 0; turn < 20; turn++) {
|
||||
for (const message of sent.splice(0)) {
|
||||
if (message.channel === WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL) {
|
||||
const requestId = requestIdOf(message.payload)
|
||||
await handlers.get('wslMoveOrchestrator:answerSleptPanes')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
{ requestId, sleptPanes }
|
||||
)
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the first import transforms the wiring's module graph, which alone can pass the 30 s test
|
||||
// timeout on a loaded machine; later imports after resetModules reuse the transform.
|
||||
beforeAll(async () => {
|
||||
await import('./wsl-move-orchestrator-wiring')
|
||||
}, 120_000)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
handlers.clear()
|
||||
sent.length = 0
|
||||
host.statuses = []
|
||||
host.listeners.clear()
|
||||
host.running = { status: 'confirmed', distros: ['Ubuntu'] }
|
||||
host.added = []
|
||||
releases.listeners.length = 0
|
||||
hostActions.listeners.length = 0
|
||||
deployments.clear()
|
||||
store.reads = 0
|
||||
store.records = new Map()
|
||||
journal.unreadable = false
|
||||
driver.previewWslMove.mockClear()
|
||||
driver.moveProjectsToWslHost.mockClear()
|
||||
})
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('WSL move orchestrator wiring', () => {
|
||||
it('stays dark with the gate closed: nothing read, nothing listed', async () => {
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
expect(store.reads).toBe(0)
|
||||
expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([])
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('moves a distro whose host is ready at start, with the window slept panes', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
await answerWindow()
|
||||
expect(driver.previewWslMove).toHaveBeenCalledWith(expect.anything(), {
|
||||
distro: 'Ubuntu',
|
||||
sleptPanes
|
||||
})
|
||||
expect(driver.moveProjectsToWslHost).toHaveBeenCalledWith(expect.anything(), {
|
||||
distro: 'Ubuntu',
|
||||
sleptPanes
|
||||
})
|
||||
})
|
||||
|
||||
it('starts on a host reaching ready, never on one that is not', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'connecting', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
await answerWindow()
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
for (const listener of host.listeners) {
|
||||
listener()
|
||||
}
|
||||
await answerWindow()
|
||||
expect(driver.moveProjectsToWslHost).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('makes no automatic start with ORCA_WSL_MOVE_AUTO=0', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
vi.stubEnv('ORCA_WSL_MOVE_AUTO', '0')
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
await answerWindow()
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a slept-pane answer from another window', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
const request = sent.find(
|
||||
(message) => message.channel === WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL
|
||||
)
|
||||
const requestId = requestIdOf(request?.payload)
|
||||
await handlers.get('wslMoveOrchestrator:answerSleptPanes')?.(
|
||||
{ sender: { id: 99 } },
|
||||
{ requestId, sleptPanes }
|
||||
)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('T2: a bridge project opened on a running distro adds its host and moves it', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
await register()
|
||||
await handlers.get('wslMoveOrchestrator:projectOpened')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
'Ubuntu'
|
||||
)
|
||||
await answerWindow()
|
||||
expect(host.added).toEqual(['Ubuntu'])
|
||||
expect(driver.moveProjectsToWslHost).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('T2: never adds a host for a stopped distro or one whose state cannot be read', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
await register()
|
||||
host.running = { status: 'confirmed', distros: [] }
|
||||
await handlers.get('wslMoveOrchestrator:projectOpened')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
'Ubuntu'
|
||||
)
|
||||
host.running = { status: 'unavailable' }
|
||||
await handlers.get('wslMoveOrchestrator:projectOpened')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
'Debian'
|
||||
)
|
||||
await answerWindow()
|
||||
expect(host.added).toEqual([])
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('T3: a ready host whose distro is not confirmed running is not resumed', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
host.running = { status: 'unavailable' }
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
await answerWindow()
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Review #2: "Use on this computer again" parks the distro, so opening it again moves nothing.
|
||||
it('parks a distro the user brought back', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
await register()
|
||||
for (const listener of releases.listeners) {
|
||||
listener('Ubuntu')
|
||||
}
|
||||
const records = await handlers.get('wslMoveOrchestrator:list')?.({})
|
||||
expect(records).toEqual([
|
||||
expect.objectContaining({ distro: 'Ubuntu', state: { kind: 'parked', reason: 'declined' } })
|
||||
])
|
||||
})
|
||||
|
||||
// E11 (#33843): `pendingStop` is optional until that PR lands, so the guard reads it loosely.
|
||||
it('never adds, connects or moves a host whose Remove is pending', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
deployments.set('wsl:Ubuntu', {
|
||||
environmentId: 'wsl:Ubuntu',
|
||||
distro: 'Ubuntu',
|
||||
pendingStop: { reason: 'server-still-running' }
|
||||
})
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
await handlers.get('wslMoveOrchestrator:projectOpened')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
'Ubuntu'
|
||||
)
|
||||
await answerWindow()
|
||||
expect(host.added).toEqual([])
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([
|
||||
expect.objectContaining({ state: { kind: 'parked', reason: 'removal-pending' } })
|
||||
])
|
||||
})
|
||||
|
||||
// Re-review 1: registerCoreHandlers calls this before many other handlers; it must not throw.
|
||||
it('registers everything and keeps an offered Restore when the journal is unreadable', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
journal.unreadable = true
|
||||
store.records = new Map([
|
||||
[
|
||||
'local-wsl:ubuntu',
|
||||
{
|
||||
distro: 'Ubuntu',
|
||||
state: { kind: 'restore-offered' },
|
||||
attempts: 0,
|
||||
appVersion: '1.0.0',
|
||||
updatedAt: 0
|
||||
}
|
||||
]
|
||||
])
|
||||
await expect(register()).resolves.toBeUndefined()
|
||||
for (const channel of [
|
||||
'wslMoveOrchestrator:list',
|
||||
'wslMoveOrchestrator:projectOpened',
|
||||
'wslMoveOrchestrator:userAction',
|
||||
'wslMoveOrchestrator:answerSleptPanes'
|
||||
]) {
|
||||
expect(handlers.has(channel)).toBe(true)
|
||||
}
|
||||
expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([
|
||||
expect.objectContaining({ state: { kind: 'restore-offered' } })
|
||||
])
|
||||
})
|
||||
|
||||
// A user Remove declines the host: T2 must not add it back; the user's own Add resumes.
|
||||
it('never re-adds a host the user removed, until the user adds it', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
await register()
|
||||
for (const listener of hostActions.listeners) {
|
||||
listener({ kind: 'removed', distro: 'Ubuntu' })
|
||||
}
|
||||
await handlers.get('wslMoveOrchestrator:projectOpened')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
'Ubuntu'
|
||||
)
|
||||
await answerWindow()
|
||||
expect(host.added).toEqual([])
|
||||
expect(driver.previewWslMove).not.toHaveBeenCalled()
|
||||
for (const listener of hostActions.listeners) {
|
||||
listener({ kind: 'added', distro: 'Ubuntu' })
|
||||
}
|
||||
expect(await handlers.get('wslMoveOrchestrator:list')?.({})).toEqual([
|
||||
expect.objectContaining({ distro: 'Ubuntu', state: { kind: 'pending' } })
|
||||
])
|
||||
})
|
||||
|
||||
// Review 3 #3: a release while the start waits for the window's panes must not be moved.
|
||||
it('does not start a move the user released while the window answered', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
host.statuses = [
|
||||
{ distro: 'Ubuntu', state: 'ready', environmentId: 'wsl:Ubuntu', detail: null }
|
||||
]
|
||||
await register()
|
||||
// Answer the preview's request only, then release before answering the start's.
|
||||
const answerOne = async (): Promise<boolean> => {
|
||||
for (let turn = 0; turn < 50; turn++) {
|
||||
const index = sent.findIndex(
|
||||
(message) => message.channel === WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL
|
||||
)
|
||||
if (index !== -1) {
|
||||
const [message] = sent.splice(index, 1)
|
||||
await handlers.get('wslMoveOrchestrator:answerSleptPanes')?.(
|
||||
{ sender: { id: MAIN_WINDOW_ID } },
|
||||
{ requestId: requestIdOf(message?.payload), sleptPanes }
|
||||
)
|
||||
return true
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
return false
|
||||
}
|
||||
expect(await answerOne()).toBe(true)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
sent.some(
|
||||
(message) => message.channel === WSL_MOVE_ORCHESTRATOR_SLEPT_PANES_REQUEST_CHANNEL
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
for (const listener of releases.listeners) {
|
||||
listener('Ubuntu')
|
||||
}
|
||||
expect(await answerOne()).toBe(true)
|
||||
await answerWindow()
|
||||
expect(driver.previewWslMove).toHaveBeenCalledTimes(1)
|
||||
expect(driver.moveProjectsToWslHost).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* The move orchestrator's production ports and IPC (D3a). Dark: with the internal WSL hosts gate
|
||||
* closed, or without a WSL host service, nothing is constructed and the IPC answers empty.
|
||||
* Automatic starts also stop when ORCA_WSL_MOVE_AUTO=0; explicit user actions still run.
|
||||
*/
|
||||
import { app, ipcMain, type WebContents } from 'electron'
|
||||
import { WSL_MOVE_CUTOVER_PHASES, wslMoveJournalKey } from '../../shared/wsl-move/wsl-move-cutover'
|
||||
import {
|
||||
WSL_MOVE_ORCHESTRATOR_STATUS_CHANNEL,
|
||||
type WslMoveOrchestratorRecord,
|
||||
type WslMoveOrchestratorUserAction
|
||||
} from '../../shared/wsl-move/wsl-move-orchestrator-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { listWslMoveCutoverChain } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import {
|
||||
moveProjectsToWslHost,
|
||||
previewWslMove
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-driver'
|
||||
import { wslMovedDistroVerdict } from '../persistence/moving-wsl-projects/wsl-move-moved-distro-check'
|
||||
import { WslMoveOrchestrator } from '../persistence/moving-wsl-projects/wsl-move-orchestrator'
|
||||
import type {
|
||||
WslMoveFinishedMove,
|
||||
WslMoveHostReadiness
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-orchestrator-ports'
|
||||
import { prepareWslMoveHost } from '../persistence/moving-wsl-projects/wsl-move-host-readiness'
|
||||
import { readConfirmedRunningWslDistros } from '../wsl-background-distro-gate'
|
||||
import { defaultWslOrcadEnvironmentId } from '../wsl/wsl-orcad-deploy'
|
||||
import {
|
||||
readWslMoveOrchestratorRecords,
|
||||
writeWslMoveOrchestratorRecords
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-orchestrator-store'
|
||||
import type { WslMoveDriverDeps } from '../persistence/moving-wsl-projects/wsl-move-driver-ports'
|
||||
import {
|
||||
trackWslMoveOrchestrator,
|
||||
wslMoveOrchestratorEventProps
|
||||
} from '../telemetry/wsl-move-orchestrator-telemetry'
|
||||
import { runTrackedWslMove } from '../telemetry/wsl-move-telemetry'
|
||||
import { readWslDistroRegistrations } from '../wsl/wsl-distro-registry-id'
|
||||
import { wslHostsGateOpen } from '../wsl/wsl-host-gate'
|
||||
import { getWslHostService } from '../wsl/wsl-host-service-instance'
|
||||
import { onWslHostUserAction } from '../wsl/wsl-host-user-actions'
|
||||
import { getTrustedUIRendererWebContents, getTrustedUIRendererWindow } from './ui'
|
||||
import { onWslMoveReleasedByUser, releaseWslMoveChainHere } from './wsl-move-release-here'
|
||||
import { createWslMoveUndoPorts } from './wsl-move-undo-wiring'
|
||||
import { answerWslMoveSleptPanes, requestSleptPanes } from './wsl-move-orchestrator-slept-panes'
|
||||
import { wslHostRemovalPending } from './wsl-move-host-connect'
|
||||
|
||||
export const WSL_MOVE_ORCHESTRATOR_AUTO_ENV = 'ORCA_WSL_MOVE_AUTO'
|
||||
|
||||
export const WSL_MOVE_ORCHESTRATOR_HANDLER_CHANNELS = [
|
||||
'wslMoveOrchestrator:list',
|
||||
'wslMoveOrchestrator:projectOpened',
|
||||
'wslMoveOrchestrator:userAction',
|
||||
'wslMoveOrchestrator:answerSleptPanes'
|
||||
] as const
|
||||
|
||||
const COMMITTED_FROM = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed')
|
||||
const USER_ACTIONS: readonly WslMoveOrchestratorUserAction[] = ['retry', 'not-now', 'restore']
|
||||
|
||||
function finishedWslMove(userDataPath: string, distro: string): WslMoveFinishedMove | null {
|
||||
const move = listWslMoveCutoverChain(userDataPath, wslMoveJournalKey(distro)).findLast(
|
||||
(cutover) => WSL_MOVE_CUTOVER_PHASES.indexOf(cutover.phase) >= COMMITTED_FROM
|
||||
)
|
||||
if (!move) {
|
||||
return null
|
||||
}
|
||||
const { distroGuid } = move.manifest.source
|
||||
return distroGuid
|
||||
? { migrationId: move.migrationId, distroGuid }
|
||||
: { migrationId: move.migrationId }
|
||||
}
|
||||
|
||||
function isHostReady(distro: string): boolean {
|
||||
const key = wslMoveJournalKey(distro)
|
||||
return (
|
||||
getWslHostService()
|
||||
?.getStatuses()
|
||||
.some((status) => wslMoveJournalKey(status.distro) === key && status.state === 'ready') ??
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
function prepareHost(
|
||||
userDataPath: string,
|
||||
distro: string,
|
||||
stillWanted: () => boolean
|
||||
): Promise<WslMoveHostReadiness> {
|
||||
const service = getWslHostService()
|
||||
if (!service) {
|
||||
return Promise.resolve('failed')
|
||||
}
|
||||
const environmentId = defaultWslOrcadEnvironmentId(distro)
|
||||
return prepareWslMoveHost(
|
||||
{
|
||||
readRunningDistros: readConfirmedRunningWslDistros,
|
||||
isHostReady,
|
||||
removalPending: () => wslHostRemovalPending(userDataPath, distro),
|
||||
hasHost: () => service.recordForEnvironment(environmentId) !== null,
|
||||
connect: () => service.connect(environmentId),
|
||||
add: () => service.add({ distro, startIfStopped: false })
|
||||
},
|
||||
distro,
|
||||
stillWanted
|
||||
)
|
||||
}
|
||||
|
||||
function publish(records: WslMoveOrchestratorRecord[]): void {
|
||||
const window = getTrustedUIRendererWindow()
|
||||
if (window && !window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.send(WSL_MOVE_ORCHESTRATOR_STATUS_CHANNEL, records)
|
||||
}
|
||||
}
|
||||
|
||||
function createOrchestrator(
|
||||
store: Store,
|
||||
driverDeps: () => WslMoveDriverDeps
|
||||
): WslMoveOrchestrator {
|
||||
const userDataPath = app.getPath('userData')
|
||||
return new WslMoveOrchestrator({
|
||||
now: () => Date.now(),
|
||||
appVersion: app.getVersion(),
|
||||
autoMoveEnabled: () => process.env[WSL_MOVE_ORCHESTRATOR_AUTO_ENV] !== '0',
|
||||
prepareHost: (distro, stillWanted) => prepareHost(userDataPath, distro, stillWanted),
|
||||
hostRemovalPending: (distro) => wslHostRemovalPending(userDataPath, distro),
|
||||
hasHost: (distro) =>
|
||||
getWslHostService()?.recordForEnvironment(defaultWslOrcadEnvironmentId(distro)) != null,
|
||||
isHostReady,
|
||||
preview: async (distro) =>
|
||||
previewWslMove(driverDeps(), {
|
||||
distro,
|
||||
sleptPanes: await requestSleptPanes()
|
||||
}),
|
||||
start: async (distro, stillWanted) => {
|
||||
const sleptPanes = await requestSleptPanes()
|
||||
// The user may have released or snoozed the distro while the window answered.
|
||||
if (!stillWanted()) {
|
||||
return { outcome: 'refused', code: 'wsl_move_cancelled' }
|
||||
}
|
||||
return runTrackedWslMove(driverDeps(), (deps) =>
|
||||
moveProjectsToWslHost(deps, { distro, sleptPanes })
|
||||
)
|
||||
},
|
||||
finishedMove: (distro) => finishedWslMove(userDataPath, distro),
|
||||
checkMovedDistro: async (distro, move) =>
|
||||
wslMovedDistroVerdict(await readWslDistroRegistrations(), distro, move.distroGuid),
|
||||
releaseFinishedMove: (distro, unsavedWorkspaceIds) =>
|
||||
releaseWslMoveChainHere(userDataPath, distro, unsavedWorkspaceIds, () =>
|
||||
createWslMoveUndoPorts(store, userDataPath)
|
||||
),
|
||||
load: () => readWslMoveOrchestratorRecords(userDataPath),
|
||||
save: (records) => writeWslMoveOrchestratorRecords(userDataPath, records),
|
||||
schedule: (delayMs, run) => {
|
||||
const timer = setTimeout(run, delayMs)
|
||||
timer.unref?.()
|
||||
return () => clearTimeout(timer)
|
||||
},
|
||||
track: (trigger, record, cause) =>
|
||||
trackWslMoveOrchestrator(wslMoveOrchestratorEventProps(trigger, record, cause)),
|
||||
publish
|
||||
})
|
||||
}
|
||||
|
||||
/** T1 and T3: a host reaching ready, or already ready at start; T2 arrives from the window. */
|
||||
function followHostReadiness(orchestrator: WslMoveOrchestrator): void {
|
||||
const service = getWslHostService()
|
||||
if (!service) {
|
||||
return
|
||||
}
|
||||
const ready = new Set<string>()
|
||||
const observe = (trigger: 'connect' | 'startup'): void => {
|
||||
const now = new Set<string>()
|
||||
for (const status of service.getStatuses()) {
|
||||
if (status.state !== 'ready') {
|
||||
continue
|
||||
}
|
||||
const key = wslMoveJournalKey(status.distro)
|
||||
now.add(key)
|
||||
if (!ready.has(key)) {
|
||||
void orchestrator.trigger(status.distro, trigger)
|
||||
}
|
||||
}
|
||||
ready.clear()
|
||||
for (const key of now) {
|
||||
ready.add(key)
|
||||
}
|
||||
}
|
||||
service.onStatusesChanged(() => observe('connect'))
|
||||
observe('startup')
|
||||
}
|
||||
|
||||
let orchestrator: WslMoveOrchestrator | null = null
|
||||
|
||||
/** `driverDeps` is the dialog's driver, but its host connect never boots a stopped distro. */
|
||||
export function registerWslMoveOrchestrator(
|
||||
store: Store,
|
||||
driverDeps: () => WslMoveDriverDeps
|
||||
): void {
|
||||
for (const channel of WSL_MOVE_ORCHESTRATOR_HANDLER_CHANNELS) {
|
||||
ipcMain.removeHandler(channel)
|
||||
}
|
||||
const isMainWindow = (sender: WebContents | undefined): boolean =>
|
||||
!!sender && getTrustedUIRendererWebContents()?.id === sender.id
|
||||
ipcMain.handle('wslMoveOrchestrator:list', (): WslMoveOrchestratorRecord[] =>
|
||||
orchestrator && wslHostsGateOpen() ? orchestrator.list() : []
|
||||
)
|
||||
// T2: the window opened a bridge project of this distro; the orchestrator checks it is running.
|
||||
ipcMain.handle(
|
||||
'wslMoveOrchestrator:projectOpened',
|
||||
(event: { sender?: WebContents }, distro: unknown): void => {
|
||||
if (
|
||||
orchestrator &&
|
||||
wslHostsGateOpen() &&
|
||||
isMainWindow(event.sender) &&
|
||||
typeof distro === 'string'
|
||||
) {
|
||||
void orchestrator.trigger(distro, 'project-opened')
|
||||
}
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'wslMoveOrchestrator:userAction',
|
||||
async (event: { sender?: WebContents }, request: unknown) => {
|
||||
if (
|
||||
!orchestrator ||
|
||||
!wslHostsGateOpen() ||
|
||||
!isMainWindow(event.sender) ||
|
||||
typeof request !== 'object' ||
|
||||
request === null ||
|
||||
!('distro' in request) ||
|
||||
!('action' in request) ||
|
||||
typeof request.distro !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const action = USER_ACTIONS.find((candidate) => candidate === request.action)
|
||||
if (!action) {
|
||||
return null
|
||||
}
|
||||
const unsaved =
|
||||
'unsavedWorkspaceIds' in request && Array.isArray(request.unsavedWorkspaceIds)
|
||||
? request.unsavedWorkspaceIds.filter((id): id is string => typeof id === 'string')
|
||||
: []
|
||||
return orchestrator.userAction(request.distro, action, { unsavedWorkspaceIds: unsaved })
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'wslMoveOrchestrator:answerSleptPanes',
|
||||
(event: { sender?: WebContents }, request: unknown): void => {
|
||||
if (
|
||||
!isMainWindow(event.sender) ||
|
||||
typeof request !== 'object' ||
|
||||
request === null ||
|
||||
!('requestId' in request) ||
|
||||
typeof request.requestId !== 'string' ||
|
||||
!('sleptPanes' in request)
|
||||
) {
|
||||
return
|
||||
}
|
||||
answerWslMoveSleptPanes(request.requestId, request.sleptPanes)
|
||||
}
|
||||
)
|
||||
// Dark: nothing is constructed or read while the gate is closed.
|
||||
if (!wslHostsGateOpen() || !getWslHostService() || orchestrator) {
|
||||
return
|
||||
}
|
||||
// Registered from registerCoreHandlers: a throw here would skip every handler after it.
|
||||
try {
|
||||
const created = createOrchestrator(store, driverDeps)
|
||||
orchestrator = created
|
||||
followHostReadiness(created)
|
||||
onWslMoveReleasedByUser((distro) => created.releasedByUser(distro))
|
||||
// A Remove is the user declining the host; only their own Add (or Retry) takes it back.
|
||||
onWslHostUserAction(({ kind, distro }) =>
|
||||
kind === 'removed' ? created.releasedByUser(distro) : created.hostAddedByUser(distro)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[wsl-move] Move orchestrator did not start:', error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Move = {
|
||||
migrationId: string
|
||||
phase: string
|
||||
journalKey: string
|
||||
destinationEnvironmentId: string
|
||||
manifest: {
|
||||
source: { distro: string; scopeRepoIds: string[]; scopeFolderWorkspaceIds: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
const journal = vi.hoisted(() => ({ chain: new Array<Move>(), released: new Array<string>() }))
|
||||
const window = vi.hoisted(() => ({
|
||||
reloads: 0,
|
||||
fences: 0,
|
||||
fencesReleased: 0,
|
||||
steps: new Array<string>()
|
||||
}))
|
||||
const releaseFails = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
function move(migrationId: string, repoId: string): Move {
|
||||
return {
|
||||
migrationId,
|
||||
phase: 'retained',
|
||||
journalKey: 'local-wsl:ubuntu',
|
||||
destinationEnvironmentId: 'wsl:Ubuntu',
|
||||
manifest: {
|
||||
source: { distro: 'Ubuntu', scopeRepoIds: [repoId], scopeFolderWorkspaceIds: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../persistence/moving-wsl-projects/wsl-move-cutover-journal', () => ({
|
||||
listWslMoveCutoverChain: () => [...journal.chain]
|
||||
}))
|
||||
vi.mock('../persistence/moving-wsl-projects/wsl-move-use-locally', () => ({
|
||||
findWslMovedProjectMove: () => ({ move: journal.chain.at(-1) }),
|
||||
wslMoveWorkspacesInScope: () => [],
|
||||
// Only the newest move of the chain may come back, as the real journal enforces.
|
||||
wslMoveReleaseRefusal: (_userData: string, candidate: Move) =>
|
||||
journal.chain.at(-1)?.migrationId === candidate.migrationId
|
||||
? null
|
||||
: { released: false, reason: 'superseded' },
|
||||
releaseWslMove: (_userData: string, candidate: Move) => {
|
||||
if (releaseFails.value) {
|
||||
return { released: false, reason: 'failed', detail: 'journal' }
|
||||
}
|
||||
journal.chain = journal.chain.filter((entry) => entry.migrationId !== candidate.migrationId)
|
||||
journal.released.push(candidate.migrationId)
|
||||
return {
|
||||
released: true,
|
||||
distro: 'Ubuntu',
|
||||
repoIds: candidate.manifest.source.scopeRepoIds,
|
||||
folderWorkspaceIds: []
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../persistence/moving-wsl-projects/wsl-move-undo-spawn-fence', () => ({
|
||||
fenceHostSpawnsForWslMoveUndo: () => {
|
||||
window.fences++
|
||||
return () => {
|
||||
window.fencesReleased++
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../persistence/moving-wsl-projects/wsl-move-window-write-hold', () => ({
|
||||
holdWslMoveWindowWrites: () => {
|
||||
window.steps.push('hold')
|
||||
},
|
||||
reloadWslMoveWindowWithWritesHeld: () => {
|
||||
window.reloads++
|
||||
window.steps.push('reload')
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
onWslMoveReleasedByUser,
|
||||
releaseWslMoveChainHere,
|
||||
releaseWslMovedProjectHere
|
||||
} from './wsl-move-release-here'
|
||||
|
||||
const undoPorts = () => ({ stopHostCopies: vi.fn(async () => 'stopped' as const) })
|
||||
|
||||
beforeEach(() => {
|
||||
journal.chain = [move('m1', 'r1'), move('m2', 'r2')]
|
||||
journal.released = []
|
||||
window.reloads = 0
|
||||
window.fences = 0
|
||||
window.fencesReleased = 0
|
||||
releaseFails.value = false
|
||||
window.steps = []
|
||||
})
|
||||
|
||||
describe('releasing moved projects here', () => {
|
||||
// Review #5: one Restore must bring back every finished move, not only the newest.
|
||||
it('releases a whole chain newest first with one reload', async () => {
|
||||
const result = await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], undoPorts)
|
||||
expect(journal.released).toEqual(['m2', 'm1'])
|
||||
expect(result).toEqual({
|
||||
released: true,
|
||||
distro: 'Ubuntu',
|
||||
repoIds: ['r2', 'r1'],
|
||||
folderWorkspaceIds: []
|
||||
})
|
||||
expect(window.reloads).toBe(1)
|
||||
})
|
||||
|
||||
it('reloads for what came back even when an older move is refused', async () => {
|
||||
const stopHostCopies = vi
|
||||
.fn<() => Promise<'stopped' | 'unreachable'>>()
|
||||
.mockResolvedValueOnce('stopped')
|
||||
.mockResolvedValueOnce('unreachable')
|
||||
const result = await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], () => ({
|
||||
stopHostCopies
|
||||
}))
|
||||
expect(journal.released).toEqual(['m2'])
|
||||
expect(result).toMatchObject({ released: false, reason: 'host-unreachable' })
|
||||
expect(window.reloads).toBe(1)
|
||||
})
|
||||
|
||||
// Review #2: only the user's "Use on this computer again" parks the distro; Restore does not.
|
||||
it('tells listeners about a user release and not about a Restore', async () => {
|
||||
const heard = vi.fn()
|
||||
const stop = onWslMoveReleasedByUser(heard)
|
||||
await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], undoPorts)
|
||||
expect(heard).toHaveBeenCalledWith('Ubuntu')
|
||||
heard.mockClear()
|
||||
await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], undoPorts)
|
||||
expect(heard).not.toHaveBeenCalled()
|
||||
stop()
|
||||
})
|
||||
|
||||
// verify-15b 6c: focus left on the host copy reopened its pane after the reload and resumed
|
||||
// the agent there beside the one restored here.
|
||||
it('returns focus from each released host copy after holding writes and before the reload', async () => {
|
||||
const refocusHere = vi.fn((released: Move) => {
|
||||
window.steps.push(`refocus ${released.migrationId}`)
|
||||
})
|
||||
const result = await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], () => ({
|
||||
stopHostCopies: vi.fn(async () => 'stopped' as const),
|
||||
refocusHere
|
||||
}))
|
||||
expect(result).toMatchObject({ released: true })
|
||||
expect(window.steps).toEqual(['hold', 'refocus m2', 'refocus m1', 'reload'])
|
||||
})
|
||||
|
||||
it('returns no focus for a move that did not come back', async () => {
|
||||
const refocusHere = vi.fn()
|
||||
const stopHostCopies = vi
|
||||
.fn<() => Promise<'stopped' | 'unreachable'>>()
|
||||
.mockResolvedValueOnce('stopped')
|
||||
.mockResolvedValueOnce('unreachable')
|
||||
await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], () => ({
|
||||
stopHostCopies,
|
||||
refocusHere
|
||||
}))
|
||||
expect(refocusHere.mock.calls.map(([released]) => released.migrationId)).toEqual(['m2'])
|
||||
})
|
||||
|
||||
const automationsOff = (
|
||||
onRestore = vi.fn(async () => {}),
|
||||
applyHere = vi.fn((): (() => void) => () => {})
|
||||
) => ({
|
||||
outcome: 'stopped' as const,
|
||||
restore: onRestore,
|
||||
applyHere
|
||||
})
|
||||
|
||||
it("turns each move's host automations off, then stops its terminals, then releases it", async () => {
|
||||
const steps: string[] = []
|
||||
const result = await releaseWslMoveChainHere('/user-data', 'Ubuntu', [], () => ({
|
||||
stopHostAutomations: vi.fn(async (stopped: Move) => {
|
||||
steps.push(`automations ${stopped.migrationId}`)
|
||||
return automationsOff()
|
||||
}),
|
||||
stopHostCopies: vi.fn(async (stopped: Move) => {
|
||||
expect(journal.released).not.toContain(stopped.migrationId)
|
||||
steps.push(`terminals ${stopped.migrationId}`)
|
||||
return 'stopped' as const
|
||||
})
|
||||
}))
|
||||
expect(result).toMatchObject({ released: true })
|
||||
// Partial chains stay per move: each step names only the move being released.
|
||||
expect(steps).toEqual(['automations m2', 'terminals m2', 'automations m1', 'terminals m1'])
|
||||
})
|
||||
|
||||
// X1: a refused undo leaves the host's copies running.
|
||||
it('refuses with the host terminals untouched when the automations cannot go off', async () => {
|
||||
for (const [outcome, reason] of [
|
||||
['unreachable', 'host-unreachable'],
|
||||
['failed', 'failed']
|
||||
] as const) {
|
||||
journal.chain = [move('m1', 'r1'), move('m2', 'r2')]
|
||||
journal.released = []
|
||||
window.reloads = 0
|
||||
let operatorCloses = 0
|
||||
const result = await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({
|
||||
stopHostAutomations: vi.fn(async () => ({ outcome })),
|
||||
stopHostCopies: vi.fn(async () => {
|
||||
operatorCloses++
|
||||
return 'stopped' as const
|
||||
})
|
||||
}))
|
||||
expect(result).toMatchObject({ released: false, reason, detail: 'host-automations' })
|
||||
expect(operatorCloses).toBe(0)
|
||||
expect(journal.released).toEqual([])
|
||||
expect(window.reloads).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('turns the automations back on when the terminals then fail to stop', async () => {
|
||||
const restore = vi.fn(async () => {})
|
||||
const result = await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({
|
||||
stopHostAutomations: vi.fn(async () => automationsOff(restore)),
|
||||
stopHostCopies: vi.fn(async () => 'still-running' as const)
|
||||
}))
|
||||
expect(result).toMatchObject({ released: false, reason: 'host-still-running' })
|
||||
expect(restore).toHaveBeenCalledOnce()
|
||||
expect(journal.released).toEqual([])
|
||||
})
|
||||
|
||||
it('turns the automations back on, and undoes the write here, when the release then fails', async () => {
|
||||
releaseFails.value = true
|
||||
const restore = vi.fn(async () => {})
|
||||
const revertHere = vi.fn()
|
||||
const result = await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({
|
||||
stopHostAutomations: vi.fn(async () =>
|
||||
automationsOff(
|
||||
restore,
|
||||
vi.fn(() => revertHere)
|
||||
)
|
||||
),
|
||||
stopHostCopies: vi.fn(async () => 'stopped' as const)
|
||||
}))
|
||||
expect(result).toMatchObject({ released: false, reason: 'failed', detail: 'journal' })
|
||||
expect(revertHere).toHaveBeenCalledOnce()
|
||||
expect(restore).toHaveBeenCalledOnce()
|
||||
expect(window.fencesReleased).toBe(1)
|
||||
})
|
||||
|
||||
it('names a host whose Remove is pending, touching nothing on it', async () => {
|
||||
const stopHostCopies = vi.fn(async () => 'stopped' as const)
|
||||
const result = await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({
|
||||
stopHostAutomations: vi.fn(async () => ({ outcome: 'removal-pending' as const })),
|
||||
stopHostCopies
|
||||
}))
|
||||
expect(result).toMatchObject({
|
||||
released: false,
|
||||
reason: 'failed',
|
||||
detail: 'host-removal-pending'
|
||||
})
|
||||
expect(stopHostCopies).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the spawn fence when a port throws', async () => {
|
||||
await expect(
|
||||
releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({
|
||||
stopHostAutomations: vi.fn(async () => {
|
||||
throw new Error('boom')
|
||||
}),
|
||||
stopHostCopies: vi.fn(async () => 'stopped' as const)
|
||||
}))
|
||||
).rejects.toThrow('boom')
|
||||
expect(window.fences).toBe(1)
|
||||
expect(window.fencesReleased).toBe(1)
|
||||
expect(window.reloads).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the host automation states here as part of the release, never on a refusal', async () => {
|
||||
const applyHere = vi.fn((): (() => void) => {
|
||||
// After the terminals stopped, right before the release.
|
||||
expect(journal.released).not.toContain('m2')
|
||||
return () => {}
|
||||
})
|
||||
await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({
|
||||
stopHostAutomations: vi.fn(async () => automationsOff(undefined, applyHere)),
|
||||
stopHostCopies: vi.fn(async () => 'stopped' as const)
|
||||
}))
|
||||
expect(applyHere).toHaveBeenCalledOnce()
|
||||
expect(journal.released).toContain('m2')
|
||||
|
||||
journal.chain = [move('m1', 'r1'), move('m2', 'r2')]
|
||||
journal.released = []
|
||||
const refusedApply = vi.fn((): (() => void) => () => {})
|
||||
await releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({
|
||||
stopHostAutomations: vi.fn(async () => automationsOff(undefined, refusedApply)),
|
||||
stopHostCopies: vi.fn(async () => 'still-running' as const)
|
||||
}))
|
||||
expect(refusedApply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still turns the host automations back on when putting the copies here back throws', async () => {
|
||||
releaseFails.value = true
|
||||
const restore = vi.fn(async () => {})
|
||||
await expect(
|
||||
releaseWslMovedProjectHere('/user-data', { repoId: 'r2' }, [], () => ({
|
||||
stopHostAutomations: vi.fn(async () =>
|
||||
automationsOff(
|
||||
restore,
|
||||
vi.fn(() => () => {
|
||||
throw new Error('store busy')
|
||||
})
|
||||
)
|
||||
),
|
||||
stopHostCopies: vi.fn(async () => 'stopped' as const)
|
||||
}))
|
||||
).rejects.toThrow('store busy')
|
||||
expect(restore).toHaveBeenCalledOnce()
|
||||
expect(window.fencesReleased).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Bringing moved projects back to this computer: one project's move ("Use on this computer
|
||||
* again"), or a distro's whole chain (R38 Restore). Host copies stop first, then the moves are
|
||||
* dropped newest first, then the window reloads once to hydrate the pre-move session.
|
||||
*/
|
||||
import {
|
||||
WSL_MOVE_CUTOVER_PHASES,
|
||||
wslMoveJournalKey,
|
||||
type WslMoveCutover
|
||||
} from '../../shared/wsl-move/wsl-move-cutover'
|
||||
import type { WslMoveUseLocallyResult } from '../../shared/wsl-move/wsl-moved-project'
|
||||
import { listWslMoveCutoverChain } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import type { WslMoveProjectRef } from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { fenceHostSpawnsForWslMoveUndo } from '../persistence/moving-wsl-projects/wsl-move-undo-spawn-fence'
|
||||
import {
|
||||
findWslMovedProjectMove,
|
||||
releaseWslMove,
|
||||
wslMoveReleaseRefusal,
|
||||
wslMoveWorkspacesInScope
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-use-locally'
|
||||
import {
|
||||
holdWslMoveWindowWrites,
|
||||
reloadWslMoveWindowWithWritesHeld
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-window-write-hold'
|
||||
import type { WslMoveUndoPorts } from './wsl-move-undo-wiring'
|
||||
import { runWslMoveHostAutomationsExclusive } from '../persistence/moving-wsl-projects/wsl-move-undo-host-automations'
|
||||
|
||||
const COMMITTED_FROM = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed')
|
||||
|
||||
const releasedByUserListeners = new Set<(distro: string) => void>()
|
||||
|
||||
/** Hears each "Use on this computer again" that released a move; Restore is not one. */
|
||||
export function onWslMoveReleasedByUser(listener: (distro: string) => void): () => void {
|
||||
releasedByUserListeners.add(listener)
|
||||
return () => releasedByUserListeners.delete(listener)
|
||||
}
|
||||
|
||||
/** "Use on this computer again" for one project: its whole move comes back and the window reloads. */
|
||||
export async function releaseWslMovedProjectHere(
|
||||
userDataPath: string,
|
||||
ref: WslMoveProjectRef,
|
||||
unsavedWorkspaceIdsAsked: readonly string[],
|
||||
createUndoPorts: () => WslMoveUndoPorts
|
||||
): Promise<WslMoveUseLocallyResult> {
|
||||
const found = findWslMovedProjectMove(userDataPath, ref)
|
||||
if (!('move' in found)) {
|
||||
return found
|
||||
}
|
||||
const result = await releaseWslMovesHere(
|
||||
userDataPath,
|
||||
[found.move],
|
||||
unsavedWorkspaceIdsAsked,
|
||||
createUndoPorts
|
||||
)
|
||||
if (result.released) {
|
||||
for (const listener of releasedByUserListeners) {
|
||||
listener(result.distro)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** R38 Restore: every finished move of the distro comes back in one go, newest first. */
|
||||
export async function releaseWslMoveChainHere(
|
||||
userDataPath: string,
|
||||
distro: string,
|
||||
unsavedWorkspaceIdsAsked: readonly string[],
|
||||
createUndoPorts: () => WslMoveUndoPorts
|
||||
): Promise<WslMoveUseLocallyResult> {
|
||||
const moves = listWslMoveCutoverChain(userDataPath, wslMoveJournalKey(distro))
|
||||
.filter((move) => WSL_MOVE_CUTOVER_PHASES.indexOf(move.phase) >= COMMITTED_FROM)
|
||||
.toReversed()
|
||||
return moves.length > 0
|
||||
? releaseWslMovesHere(userDataPath, moves, unsavedWorkspaceIdsAsked, createUndoPorts)
|
||||
: { released: false, reason: 'not-moved' }
|
||||
}
|
||||
|
||||
async function releaseWslMovesHere(
|
||||
userDataPath: string,
|
||||
newestFirst: readonly WslMoveCutover[],
|
||||
unsavedWorkspaceIdsAsked: readonly string[],
|
||||
createUndoPorts: () => WslMoveUndoPorts
|
||||
): Promise<WslMoveUseLocallyResult> {
|
||||
// Only the moves' own workspaces reload from their pre-move state; other unsaved files stay.
|
||||
const unsavedWorkspaceIds = [
|
||||
...new Set(
|
||||
newestFirst.flatMap((move) => wslMoveWorkspacesInScope(move, unsavedWorkspaceIdsAsked))
|
||||
)
|
||||
]
|
||||
if (unsavedWorkspaceIds.length > 0) {
|
||||
return { released: false, reason: 'unsaved-files', unsavedWorkspaceIds }
|
||||
}
|
||||
const first = newestFirst[0]
|
||||
// Why before the stop: a refused undo must leave the host's copies running (X1).
|
||||
const refusal = wslMoveReleaseRefusal(userDataPath, first)
|
||||
if (refusal) {
|
||||
return refusal
|
||||
}
|
||||
// Fenced until the reload: an open host pane would otherwise start its stopped agent again.
|
||||
const releaseSpawnFence = fenceHostSpawnsForWslMoveUndo(first.destinationEnvironmentId)
|
||||
let fenceHandedToReload = false
|
||||
try {
|
||||
const repoIds: string[] = []
|
||||
const folderWorkspaceIds: string[] = []
|
||||
const released: WslMoveCutover[] = []
|
||||
let failure: Extract<WslMoveUseLocallyResult, { released: false }> | null = null
|
||||
for (const move of newestFirst) {
|
||||
const moveRefusal = move === first ? null : wslMoveReleaseRefusal(userDataPath, move)
|
||||
if (moveRefusal) {
|
||||
failure = moveRefusal
|
||||
break
|
||||
}
|
||||
// A left-off retry must not turn a host copy back on between this move's steps.
|
||||
const step = await runWslMoveHostAutomationsExclusive(() =>
|
||||
releaseOneMoveHere(userDataPath, move, createUndoPorts())
|
||||
)
|
||||
if (!step.released) {
|
||||
failure = step
|
||||
break
|
||||
}
|
||||
released.push(move)
|
||||
repoIds.push(...step.repoIds)
|
||||
folderWorkspaceIds.push(...step.folderWorkspaceIds)
|
||||
}
|
||||
if (repoIds.length === 0 && folderWorkspaceIds.length === 0) {
|
||||
return failure ?? { released: false, reason: 'not-moved' }
|
||||
}
|
||||
// Released moves stay released even if an older one refused; the reload shows what came back.
|
||||
holdWslMoveWindowWrites()
|
||||
// Focus left on a host copy would reopen its panes after the reload and restart their agents.
|
||||
const ports = createUndoPorts()
|
||||
for (const move of released) {
|
||||
ports.refocusHere?.(move)
|
||||
}
|
||||
fenceHandedToReload = true
|
||||
reloadWslMoveWindowWithWritesHeld(releaseSpawnFence)
|
||||
return (
|
||||
failure ?? {
|
||||
released: true,
|
||||
distro: first.manifest.source.distro,
|
||||
repoIds,
|
||||
folderWorkspaceIds
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
// Any refusal, or a throw, before the reload took the fence over releases it here.
|
||||
if (!fenceHandedToReload) {
|
||||
releaseSpawnFence()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One move: host automations off, host terminals stopped, then released; refused, all undone. */
|
||||
async function releaseOneMoveHere(
|
||||
userDataPath: string,
|
||||
move: WslMoveCutover,
|
||||
ports: WslMoveUndoPorts
|
||||
): Promise<WslMoveUseLocallyResult> {
|
||||
// Automations first, as they undo cleanly: a refusal must leave the host's terminals running (X1).
|
||||
const automations = (await ports.stopHostAutomations?.(move)) ?? {
|
||||
outcome: 'stopped',
|
||||
restore: async () => {},
|
||||
applyHere: () => () => {}
|
||||
}
|
||||
if (automations.outcome !== 'stopped') {
|
||||
return {
|
||||
released: false,
|
||||
reason: automations.outcome === 'unreachable' ? 'host-unreachable' : 'failed',
|
||||
detail:
|
||||
automations.outcome === 'removal-pending' ? 'host-removal-pending' : 'host-automations'
|
||||
}
|
||||
}
|
||||
// The host's copies stop next, or an agent the move resumed there runs beside this one.
|
||||
const stop = await ports.stopHostCopies(move).catch(() => 'unreachable' as const)
|
||||
if (stop !== 'stopped') {
|
||||
await automations.restore()
|
||||
return {
|
||||
released: false,
|
||||
reason: stop === 'unreachable' ? 'host-unreachable' : 'host-still-running'
|
||||
}
|
||||
}
|
||||
// Written here just before the release (the record keeps the choice), and undone if it refuses.
|
||||
const revertHere = automations.applyHere()
|
||||
const release = releaseWslMove(userDataPath, move)
|
||||
if (!release.released) {
|
||||
try {
|
||||
revertHere()
|
||||
} finally {
|
||||
// The host's copies go back on even if putting the copies here back failed.
|
||||
await automations.restore()
|
||||
}
|
||||
}
|
||||
return release
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** The app window as the WSL move's write hold reloads it (wsl-move-window-write-hold.ts). */
|
||||
import type { WebContents } from 'electron'
|
||||
import type { WslMoveReloadableWindow } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold'
|
||||
|
||||
export function wslMoveReloadableWindow(contents: WebContents): WslMoveReloadableWindow {
|
||||
return {
|
||||
isDestroyed: () => contents.isDestroyed(),
|
||||
reload: () => contents.reload(),
|
||||
allowUnload: () => {
|
||||
const allow = (event: Electron.Event): void => event.preventDefault()
|
||||
contents.on('will-prevent-unload', allow)
|
||||
return () => contents.removeListener('will-prevent-unload', allow)
|
||||
},
|
||||
onLoadedOrGone: (listener: () => void) => {
|
||||
contents.once('did-finish-load', listener)
|
||||
contents.once('destroyed', listener)
|
||||
return () => {
|
||||
contents.removeListener('did-finish-load', listener)
|
||||
contents.removeListener('destroyed', listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate'
|
||||
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
|
||||
import {
|
||||
loadWslMoveFence,
|
||||
resetWslMoveFenceForTests
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { setWslMoveReloadableWindow } from '../persistence/moving-wsl-projects/wsl-move-window-write-hold'
|
||||
import {
|
||||
registerWslMoveDesktopRewriteStore,
|
||||
resetWslMoveDesktopRewriteStoresForTests
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores'
|
||||
import {
|
||||
isHostSpawnFencedForWslMoveUndo,
|
||||
resetWslMoveUndoSpawnFencesForTests
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-undo-spawn-fence'
|
||||
import type { WslMoveHostCopyStop } from '../persistence/moving-wsl-projects/wsl-move-host-copy-stop'
|
||||
|
||||
type Handler = (event: unknown, request?: unknown) => unknown
|
||||
|
||||
const handlers = vi.hoisted(() => new Map<string, Handler>())
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
removeHandler: (channel: string) => handlers.delete(channel),
|
||||
handle: (channel: string, handler: Handler) => handlers.set(channel, handler)
|
||||
}
|
||||
}))
|
||||
|
||||
import { registerWslMoveHandlers } from './wsl-move-handlers'
|
||||
|
||||
const MAIN_WINDOW_ID = 7
|
||||
const HOST = cutover('move-1', 'destination-committed').destinationEnvironmentId
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-undo-open-'))
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
registerWslMoveDesktopRewriteStore({ id: 'renderer-a', side: 'renderer' })
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
loadWslMoveFence(userDataPath)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
setWslMoveReloadableWindow(() => null)
|
||||
resetWslMoveDesktopRewriteStoresForTests()
|
||||
resetWslMoveFenceForTests()
|
||||
resetWslMoveUndoSpawnFencesForTests()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function register(stopHostCopies: () => Promise<WslMoveHostCopyStop>): void {
|
||||
registerWslMoveHandlers(
|
||||
() => userDataPath,
|
||||
(senderId) => senderId === MAIN_WINDOW_ID,
|
||||
undefined,
|
||||
() => ({ stopHostCopies })
|
||||
)
|
||||
}
|
||||
|
||||
const flushStorageData = vi.fn()
|
||||
const undo = (sender: unknown = { id: MAIN_WINDOW_ID, session: { flushStorageData } }) =>
|
||||
handlers.get('wslMove:useProjectHereAgain')?.({ sender }, { kind: 'repo', id: 'repo-1' })
|
||||
|
||||
// The open host pane resumed its agent on the host 1.6 s after undo stopped it.
|
||||
describe('undo with the host pane open', () => {
|
||||
it("refuses the window's host starts from the stop until the reloaded page has loaded", async () => {
|
||||
let fencedDuringStop = false
|
||||
register(async () => {
|
||||
// The open pane sees its terminal exit and starts it again on the host.
|
||||
fencedDuringStop = isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.create')
|
||||
return 'stopped'
|
||||
})
|
||||
const page = { loaded: () => {} }
|
||||
const mainWindow = {
|
||||
id: MAIN_WINDOW_ID,
|
||||
session: { flushStorageData },
|
||||
isDestroyed: () => false,
|
||||
reload: vi.fn(),
|
||||
allowUnload: () => () => {},
|
||||
onLoadedOrGone: (listener: () => void) => {
|
||||
page.loaded = listener
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
setWslMoveReloadableWindow(() => mainWindow)
|
||||
|
||||
expect(await undo(mainWindow)).toMatchObject({ released: true })
|
||||
expect(fencedDuringStop).toBe(true)
|
||||
// The old page can still start host panes until the reload replaces it.
|
||||
expect(isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.ensureAgentSession')).toBe(true)
|
||||
expect(isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.list')).toBe(false)
|
||||
expect(isHostSpawnFencedForWslMoveUndo('wsl:other', 'terminal.create')).toBe(false)
|
||||
page.loaded()
|
||||
expect(isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.create')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['still-running', 'unreachable'] as const)(
|
||||
'lifts the fence when the host stop is %s, leaving the host usable',
|
||||
async (stop) => {
|
||||
register(async () => stop)
|
||||
expect(await undo()).toMatchObject({ released: false })
|
||||
expect(isHostSpawnFencedForWslMoveUndo(HOST, 'terminal.create')).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { wslMoveCutoverFixture } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { wslMoverProfile } from '../persistence/__fixtures__/wsl-mover/wsl-mover-profiles'
|
||||
import { createWslMoveUndoPorts } from './wsl-move-undo-wiring'
|
||||
|
||||
const rpc = vi.hoisted(() => ({
|
||||
call: vi.fn(async (..._args: unknown[]): Promise<unknown> => null)
|
||||
}))
|
||||
vi.mock('./runtime-environments/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: (...args: unknown[]) => rpc.call(...args)
|
||||
}))
|
||||
const host = vi.hoisted(() => ({
|
||||
disabled: new Array<string[]>(),
|
||||
enabledOnHost: new Map<string, boolean>(),
|
||||
listed: true,
|
||||
unreadable: false,
|
||||
removalPending: false
|
||||
}))
|
||||
vi.mock('../../shared/runtime-environment-store', () => ({
|
||||
listEnvironments: () => {
|
||||
if (host.unreadable) {
|
||||
throw new Error('environment store unreadable')
|
||||
}
|
||||
return host.listed ? [{ id: 'wsl:Ubuntu' }] : []
|
||||
}
|
||||
}))
|
||||
vi.mock('./wsl-move-host-connect', () => ({
|
||||
wslHostRemovalPending: () => host.removalPending
|
||||
}))
|
||||
vi.mock('./wsl-host-automation-disable', () => ({
|
||||
readWslHostAutomationStates: async (_userData: string, _environmentId: string, ids: string[]) =>
|
||||
Object.fromEntries(ids.map((id) => [id, host.enabledOnHost.get(id) ?? false])),
|
||||
disableWslHostAutomations: async (_userData: string, _environmentId: string, ids: string[]) => {
|
||||
host.disabled.push([...ids])
|
||||
return [...ids]
|
||||
},
|
||||
restoreWslHostAutomations: async () => {}
|
||||
}))
|
||||
|
||||
const MOVED = wslMoverProfile('mixed-windows-wsl', 'source').session.activeWorktreeId ?? ''
|
||||
const MOVED_ON_HOST = 'repo-a::/home/dev/app'
|
||||
|
||||
// verify-15b 6c: after undo the window reopened the host copy it was focused on, and the host
|
||||
// resumed the pane's agent there beside the one restored here.
|
||||
describe("undo's return of focus", () => {
|
||||
it('points the local session back at the project here before the window reloads', () => {
|
||||
const move = wslMoveCutoverFixture(
|
||||
'move-1',
|
||||
'retained',
|
||||
{},
|
||||
{ repoIds: ['repo-a'], folderWorkspaceIds: [] }
|
||||
)
|
||||
let session: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { [MOVED]: [] },
|
||||
activeWorktreeId: MOVED_ON_HOST,
|
||||
activeWorkspaceKey: `worktree:${MOVED_ON_HOST}`,
|
||||
activeWorkspaceExecutionHostId: toRuntimeExecutionHostId(move.destinationEnvironmentId)
|
||||
}
|
||||
const store = {
|
||||
getWorkspaceSession: () => session,
|
||||
listAutomations: () => [],
|
||||
updateAutomation: vi.fn(),
|
||||
patchWorkspaceSession: vi.fn((patch: Partial<WorkspaceSessionState>, hostId?: string) => {
|
||||
expect(hostId).toBe(LOCAL_EXECUTION_HOST_ID)
|
||||
session = { ...session, ...patch }
|
||||
})
|
||||
}
|
||||
createWslMoveUndoPorts(store, '/user-data').refocusHere?.(move)
|
||||
expect(session).toMatchObject({
|
||||
activeWorkspaceExecutionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
activeWorktreeId: MOVED,
|
||||
activeWorkspaceKey: `worktree:${MOVED}`
|
||||
})
|
||||
// A second call finds focus already here and writes nothing.
|
||||
store.patchWorkspaceSession.mockClear()
|
||||
createWslMoveUndoPorts(store, '/user-data').refocusHere?.(move)
|
||||
expect(store.patchWorkspaceSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("undo's host automation port", () => {
|
||||
const move = wslMoveCutoverFixture(
|
||||
'move-1',
|
||||
'retained',
|
||||
{},
|
||||
{ repoIds: ['repo-a'] },
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the port reads only the moved automations' ids.
|
||||
{ automations: [{ id: 'off-on-host' }, { id: 'on-on-host' }, { id: 'same' }] } as never
|
||||
)
|
||||
const localRows = () => [
|
||||
// d3a case 1: turned off on the host after the move, still on here.
|
||||
{ id: 'off-on-host', enabled: true },
|
||||
// d3a case 2: off here before the move, turned on on the host after.
|
||||
{ id: 'on-on-host', enabled: false },
|
||||
{ id: 'same', enabled: true },
|
||||
{ id: 'not-moved', enabled: true }
|
||||
]
|
||||
const storeWith = () => {
|
||||
const updateAutomation = vi.fn()
|
||||
return {
|
||||
store: {
|
||||
getWorkspaceSession: () => getDefaultWorkspaceSession(),
|
||||
patchWorkspaceSession: vi.fn(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the port reads only `id` and `enabled`.
|
||||
listAutomations: () => localRows() as never,
|
||||
updateAutomation
|
||||
},
|
||||
updateAutomation
|
||||
}
|
||||
}
|
||||
|
||||
it("turns off every host copy of the move's automations, then carries the host's states here", async () => {
|
||||
host.disabled = []
|
||||
host.listed = true
|
||||
host.enabledOnHost = new Map([
|
||||
['off-on-host', false],
|
||||
['on-on-host', true],
|
||||
['same', true]
|
||||
])
|
||||
const { store, updateAutomation } = storeWith()
|
||||
const stopped = await createWslMoveUndoPorts(store, '/user-data').stopHostAutomations?.(move)
|
||||
// Every moved automation is read on the host; those on there are turned off.
|
||||
expect(host.disabled).toEqual([['on-on-host', 'same']])
|
||||
if (stopped?.outcome !== 'stopped') {
|
||||
throw new Error('expected stopped')
|
||||
}
|
||||
// Nothing changes here until the release applies it.
|
||||
expect(updateAutomation).not.toHaveBeenCalled()
|
||||
stopped.applyHere()
|
||||
expect(updateAutomation.mock.calls).toEqual([
|
||||
['off-on-host', { enabled: false }],
|
||||
['on-on-host', { enabled: true }]
|
||||
])
|
||||
})
|
||||
|
||||
it('asks a removed host nothing', async () => {
|
||||
host.disabled = []
|
||||
host.listed = false
|
||||
const { store } = storeWith()
|
||||
expect(
|
||||
(await createWslMoveUndoPorts(store, '/user-data').stopHostAutomations?.(move))?.outcome
|
||||
).toBe('stopped')
|
||||
expect(host.disabled).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("undo's host automation port, refusing", () => {
|
||||
const move = wslMoveCutoverFixture('move-1', 'retained', {}, { repoIds: ['repo-a'] })
|
||||
const store = {
|
||||
getWorkspaceSession: () => getDefaultWorkspaceSession(),
|
||||
patchWorkspaceSession: vi.fn(),
|
||||
listAutomations: () => [],
|
||||
updateAutomation: vi.fn()
|
||||
}
|
||||
|
||||
it('fails, without throwing past the spawn fence, when the environment store is unreadable', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
host.unreadable = true
|
||||
try {
|
||||
expect(await createWslMoveUndoPorts(store, '/user-data').stopHostAutomations?.(move)).toEqual(
|
||||
{ outcome: 'failed' }
|
||||
)
|
||||
} finally {
|
||||
host.unreadable = false
|
||||
}
|
||||
})
|
||||
|
||||
it('names a host whose Remove is pending (E11) rather than calling it unreachable', async () => {
|
||||
host.listed = true
|
||||
host.removalPending = true
|
||||
try {
|
||||
expect(await createWslMoveUndoPorts(store, '/user-data').stopHostAutomations?.(move)).toEqual(
|
||||
{ outcome: 'removal-pending' }
|
||||
)
|
||||
} finally {
|
||||
host.removalPending = false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// verify-15c: with the host's orcad stopped, undo of a moved project this computer kept no tabs
|
||||
// for asked the host nothing, released it, and its host shell kept running.
|
||||
describe("undo's stop of the host's terminals", () => {
|
||||
const move = wslMoveCutoverFixture('move-1', 'retained', {}, { repoIds: ['repo-a'] })
|
||||
// A moved project keeps only its chats here, so this one names no workspace at all.
|
||||
const store = {
|
||||
getWorkspaceSession: () => getDefaultWorkspaceSession(),
|
||||
patchWorkspaceSession: vi.fn(),
|
||||
listAutomations: () => [],
|
||||
updateAutomation: vi.fn()
|
||||
}
|
||||
|
||||
it('refuses when the host does not answer, though this computer names none of its workspaces', async () => {
|
||||
host.listed = true
|
||||
rpc.call.mockReset()
|
||||
rpc.call.mockRejectedValue(new Error('timed out'))
|
||||
expect(await createWslMoveUndoPorts(store, '/user-data').stopHostCopies(move)).toBe(
|
||||
'unreachable'
|
||||
)
|
||||
expect(rpc.call.mock.calls.map(([, , method]) => method)).toEqual(['terminal.list'])
|
||||
})
|
||||
|
||||
it("sleeps the host's workspaces of the move that run a terminal, and only those", async () => {
|
||||
host.listed = true
|
||||
const live = new Set(['repo-a::/home/dev/repo-a'])
|
||||
rpc.call.mockReset()
|
||||
rpc.call.mockImplementation(async (...args: unknown[]) => {
|
||||
const [, , method, params] = args
|
||||
const worktree =
|
||||
typeof params === 'object' && params !== null && 'worktree' in params
|
||||
? String(params.worktree).slice('id:'.length)
|
||||
: null
|
||||
if (method === 'worktree.sleep' && worktree) {
|
||||
live.delete(worktree)
|
||||
return { ok: true, result: {} }
|
||||
}
|
||||
const terminals = [
|
||||
{ worktreeId: 'repo-a::/home/dev/repo-a', connected: live.has('repo-a::/home/dev/repo-a') },
|
||||
{ worktreeId: 'repo-other::/home/dev/other', connected: true }
|
||||
].filter((terminal) => worktree === null || terminal.worktreeId === worktree)
|
||||
return { ok: true, result: { terminals, truncated: false } }
|
||||
})
|
||||
expect(await createWslMoveUndoPorts(store, '/user-data').stopHostCopies(move)).toBe('stopped')
|
||||
const slept = rpc.call.mock.calls
|
||||
.filter(([, , method]) => method === 'worktree.sleep')
|
||||
.map(([, , , params]) => params)
|
||||
expect(slept).toEqual([{ worktree: 'id:repo-a::/home/dev/repo-a' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The production port of "Use on this computer again": the host's copies of the project's
|
||||
* terminals, reached over its paired runtime.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { WslMoveCutover } from '../../shared/wsl-move/wsl-move-cutover'
|
||||
import type { Store } from '../persistence'
|
||||
import { wslMoveDesktopRewriteContext } from '../persistence/moving-wsl-projects/wsl-move-desktop-rewrite'
|
||||
import {
|
||||
stopWslMoveHostCopies,
|
||||
type WslMoveHostCopyStop
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-host-copy-stop'
|
||||
import { wslMoveRewriteScope } from '../persistence/moving-wsl-projects/wsl-move-rewrite-scope'
|
||||
import { wslMoveReleasedSessionFocusPatch } from '../persistence/moving-wsl-projects/wsl-move-session-rewrite'
|
||||
import { applyWslMoveSessionFocus } from '../persistence/moving-wsl-projects/wsl-move-main-rewrite-stores'
|
||||
import { listEnvironments } from '../../shared/runtime-environment-store'
|
||||
import { callRuntimeEnvironment } from './runtime-environments/runtime-environment-transport-routing'
|
||||
import {
|
||||
stopWslMoveHostAutomations,
|
||||
wslMoveMovedAutomationIds,
|
||||
type WslMoveHostAutomationStop
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-undo-host-automations'
|
||||
import { scheduleWslMoveRewriteRetry } from '../persistence/moving-wsl-projects/wsl-move-rewrite-retry'
|
||||
import { wslHostRemovalPending } from './wsl-move-host-connect'
|
||||
import {
|
||||
disableWslHostAutomations,
|
||||
readWslHostAutomationStates,
|
||||
restoreWslHostAutomations
|
||||
} from './wsl-host-automation-disable'
|
||||
|
||||
export type WslMoveUndoPorts = {
|
||||
stopHostCopies: (move: WslMoveCutover) => Promise<WslMoveHostCopyStop>
|
||||
/** First, before the terminals stop: the host's copies of the move's automations turn off. */
|
||||
stopHostAutomations?: (move: WslMoveCutover) => Promise<WslMoveHostAutomationStop>
|
||||
/** After the release, with window writes held: focus on the host copy returns here. */
|
||||
refocusHere?: (move: WslMoveCutover) => void
|
||||
}
|
||||
|
||||
// What undo reads of the host's terminal list: whether each terminal is still running.
|
||||
const HostTerminalList = z.object({
|
||||
terminals: z.array(
|
||||
z.object({ connected: z.boolean(), worktreeId: z.string().optional() }).passthrough()
|
||||
),
|
||||
truncated: z.boolean().optional()
|
||||
})
|
||||
|
||||
// Far above any host's terminal count; a list cut off at it cannot prove what runs there.
|
||||
const HOST_TERMINAL_LIST_LIMIT = 10_000
|
||||
|
||||
const SLEEP_TIMEOUT_MS = 60_000
|
||||
const LIST_TIMEOUT_MS = 10_000
|
||||
|
||||
export function createWslMoveUndoPorts(
|
||||
store: Pick<
|
||||
Store,
|
||||
'getWorkspaceSession' | 'patchWorkspaceSession' | 'listAutomations' | 'updateAutomation'
|
||||
>,
|
||||
userDataPath: string
|
||||
): WslMoveUndoPorts {
|
||||
return {
|
||||
stopHostCopies: async (move) => {
|
||||
// A host the user removed ran its terminals down with it; nothing of the project runs there.
|
||||
if (!listEnvironments(userDataPath).some((env) => env.id === move.destinationEnvironmentId)) {
|
||||
return 'stopped'
|
||||
}
|
||||
const scope = wslMoveRewriteScope(wslMoveDesktopRewriteContext(userDataPath, move))
|
||||
const local = store.getWorkspaceSession(LOCAL_EXECUTION_HOST_ID)
|
||||
const hostIds = [...new Set(Object.keys(local.tabsByWorktree).filter(scope.inScope))].map(
|
||||
(id) => scope.worktreeId(id)
|
||||
)
|
||||
const call = async (method: string, params: unknown, timeoutMs: number) => {
|
||||
try {
|
||||
return await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
move.destinationEnvironmentId,
|
||||
method,
|
||||
params,
|
||||
timeoutMs
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return stopWslMoveHostCopies(hostIds, {
|
||||
workspacesWithTerminals: async () => {
|
||||
const response = await call(
|
||||
'terminal.list',
|
||||
{ limit: HOST_TERMINAL_LIST_LIMIT, includeVisualLayouts: false },
|
||||
LIST_TIMEOUT_MS
|
||||
)
|
||||
const listed = response?.ok ? HostTerminalList.safeParse(response.result) : null
|
||||
// An error answer, an unreadable one or a cut-off list cannot prove what runs there.
|
||||
if (!listed?.success || listed.data.truncated === true) {
|
||||
return null
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
listed.data.terminals
|
||||
.map((terminal) => terminal.worktreeId)
|
||||
.filter((id): id is string => typeof id === 'string' && scope.inScope(id))
|
||||
)
|
||||
]
|
||||
},
|
||||
sleepWorkspace: async (id) => {
|
||||
const response = await call('worktree.sleep', { worktree: `id:${id}` }, SLEEP_TIMEOUT_MS)
|
||||
return response === null ? null : response.ok
|
||||
},
|
||||
liveTerminals: async (id) => {
|
||||
const response = await call('terminal.list', { worktree: `id:${id}` }, LIST_TIMEOUT_MS)
|
||||
if (response === null) {
|
||||
return null
|
||||
}
|
||||
// A workspace the host no longer lists runs nothing there.
|
||||
if (!response.ok) {
|
||||
return 0
|
||||
}
|
||||
const listed = HostTerminalList.safeParse(response.result)
|
||||
return listed.success
|
||||
? listed.data.terminals.filter((terminal) => terminal.connected).length
|
||||
: 0
|
||||
},
|
||||
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
})
|
||||
},
|
||||
stopHostAutomations: async (move) => {
|
||||
// Inside a try: a throw here would otherwise escape past undo's spawn fence.
|
||||
try {
|
||||
// A removed host took its automations with it.
|
||||
if (
|
||||
!listEnvironments(userDataPath).some((env) => env.id === move.destinationEnvironmentId)
|
||||
) {
|
||||
return { outcome: 'stopped', restore: async () => {}, applyHere: () => () => {} }
|
||||
}
|
||||
// E11: a Remove that could not prove the server stopped; the host is not to be touched.
|
||||
if (wslHostRemovalPending(userDataPath, move.manifest.source.distro)) {
|
||||
return { outcome: 'removal-pending' }
|
||||
}
|
||||
const environmentId = move.destinationEnvironmentId
|
||||
const stop = await stopWslMoveHostAutomations(
|
||||
userDataPath,
|
||||
move.migrationId,
|
||||
wslMoveMovedAutomationIds(move),
|
||||
{
|
||||
read: (ids) => readWslHostAutomationStates(userDataPath, environmentId, ids),
|
||||
disable: (ids) => disableWslHostAutomations(userDataPath, environmentId, ids),
|
||||
restore: (ids) => restoreWslHostAutomations(userDataPath, environmentId, ids),
|
||||
listHere: () => store.listAutomations(),
|
||||
setHere: (id, enabled) => {
|
||||
store.updateAutomation(id, { enabled })
|
||||
}
|
||||
}
|
||||
)
|
||||
// Whatever a refusal left off on the host is retried until it is back on.
|
||||
if (stop.outcome !== 'stopped') {
|
||||
scheduleWslMoveRewriteRetry(userDataPath)
|
||||
return stop
|
||||
}
|
||||
return {
|
||||
...stop,
|
||||
restore: async () => {
|
||||
await stop.restore()
|
||||
scheduleWslMoveRewriteRetry(userDataPath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[wsl-move] Could not read what undo turns off on the host:', error)
|
||||
return { outcome: 'failed' }
|
||||
}
|
||||
},
|
||||
refocusHere: (move) => {
|
||||
applyWslMoveSessionFocus(
|
||||
store,
|
||||
wslMoveRewriteScope(wslMoveDesktopRewriteContext(userDataPath, move)),
|
||||
wslMoveReleasedSessionFocusPatch
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ const { prepareHooks, resolveHome, listHomes } = vi.hoisted(() => ({
|
||||
listHomes: vi.fn(() => ['/home/u/.local/share/orca/codex-accounts/a1/home'])
|
||||
}))
|
||||
|
||||
const wslHost = vi.hoisted(() => vi.fn(() => false))
|
||||
vi.mock('../ipc/wsl-guest-windows-drive-path', () => ({ isWslHostOrcadProcess: wslHost }))
|
||||
vi.mock('../codex/codex-account-home-hook', () => ({
|
||||
reconcileCodexAccountHomeHook: prepareHooks
|
||||
}))
|
||||
@@ -41,6 +43,19 @@ describe('orcadCodexAccountHomes', () => {
|
||||
prepareHooks.mockClear()
|
||||
})
|
||||
|
||||
it('on a WSL host with hooks off, leaves the bridge-shared entry alone', async () => {
|
||||
wslHost.mockReturnValue(true)
|
||||
const homes = orcadCodexAccountHomes(
|
||||
() => settings({ agentStatusHooksEnabled: false }),
|
||||
noCleanup
|
||||
)
|
||||
await expect(homes.selectLaunchHome(undefined, { CODEX_HOME: ACCOUNT_HOME })).resolves.toBe(
|
||||
ACCOUNT_HOME
|
||||
)
|
||||
expect(prepareHooks).not.toHaveBeenCalled()
|
||||
wslHost.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('puts the hook into a client-sent account home and launches on it', async () => {
|
||||
const homes = orcadCodexAccountHomes(() => settings({}), noCleanup)
|
||||
await expect(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { reconcileCodexAccountHomeHook } from '../codex/codex-account-home-hook'
|
||||
import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery'
|
||||
import type { GetSelectedCodexHomePath } from '../ipc/pty/host-env/types'
|
||||
import { isWslHostOrcadProcess } from '../ipc/wsl-guest-windows-drive-path'
|
||||
import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime'
|
||||
|
||||
/** Keep host status hooks and session discovery in client-selected managed CODEX_HOME paths. */
|
||||
@@ -21,11 +22,11 @@ export function orcadCodexAccountHomes(
|
||||
if (!home) {
|
||||
return null
|
||||
}
|
||||
await reconcileCodexAccountHomeHook(
|
||||
home,
|
||||
() => isAgentStatusHooksEnabledForAgent(getSettings(), 'codex'),
|
||||
context?.launchesCodex === true
|
||||
)
|
||||
const isEnabled = () => isAgentStatusHooksEnabledForAgent(getSettings(), 'codex')
|
||||
// R42, COMPAT(wsl-bridge): the bridge's panes share these homes; off skips until D4 drops it.
|
||||
if (isEnabled() || !isWslHostOrcadProcess()) {
|
||||
await reconcileCodexAccountHomeHook(home, isEnabled, context?.launchesCodex === true)
|
||||
}
|
||||
return home
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { advertisedOrcadMigrationBlobCapabilities } from '../../shared/orcad-migration/orcad-migration-blob-transfer'
|
||||
import { SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY } from '../../shared/session-tabs-runtime-capabilities'
|
||||
import { servesWslMovedPaneHistory } from './orcad-migration-wsl-destination'
|
||||
|
||||
/**
|
||||
* Migration blob kinds this host installs, plus the stored-scrollback read a WSL move's destination
|
||||
* serves. Terminal history and the scrollback read stay off everywhere but a WSL host.
|
||||
*/
|
||||
export function advertisedOrcadMigrationDestinationCapabilities(): string[] {
|
||||
const wslHost = servesWslMovedPaneHistory()
|
||||
return [
|
||||
...advertisedOrcadMigrationBlobCapabilities({ wslHost }),
|
||||
...(wslHost ? [SESSION_TERMINAL_SCROLLBACK_READ_RUNTIME_CAPABILITY] : [])
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,17 @@ export function servesLocalWslMigrationSource(
|
||||
return isWslHostOrcadProcess(env, platform)
|
||||
}
|
||||
|
||||
// E2E hosts stand in for a WSL host's orcad; a real one is identified by isWslHostOrcadProcess.
|
||||
export const ORCA_E2E_WSL_MOVE_DESTINATION_ENV = 'ORCA_E2E_WSL_MOVE_DESTINATION'
|
||||
|
||||
/**
|
||||
* Whether this host is a WSL move's destination: it installs moved panes' terminal history and
|
||||
* serves their stored scrollback. Every other host keeps that wire surface off.
|
||||
*/
|
||||
export function servesWslMovedPaneHistory(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return isWslHostOrcadProcess(env) || env[ORCA_E2E_WSL_MOVE_DESTINATION_ENV] === '1'
|
||||
}
|
||||
|
||||
export function assertOrcadMigrationSourceServedHere(
|
||||
source: OrcadMigrationManifestSource,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* R47: "Use on this computer again" can hand a later WSL move the generation number an earlier one
|
||||
* used. The host stages, commits and keeps receipts by migration id and manifest digest, so two
|
||||
* moves sharing a generation never collide; only the desktop's planner reads the generation.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { OrcadMigrationManifest } from '../shared/orcad-migration/orcad-migration-manifest'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import { manifest } from './persistence/migrating-orcad-catalog/persistence-orcad-migration-catalog-fixture'
|
||||
import { createStore, testState } from './persistence/persistence-test-harness'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
|
||||
}
|
||||
}))
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('./telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 1 }))
|
||||
}))
|
||||
|
||||
const GENERATION = 3
|
||||
|
||||
function wslMove(migrationId: string, repoId: string): OrcadMigrationManifest {
|
||||
const repo: Repo = {
|
||||
id: repoId,
|
||||
path: `/home/dev/${repoId}`,
|
||||
displayName: repoId,
|
||||
badgeColor: '#737373',
|
||||
addedAt: 1,
|
||||
kind: 'git'
|
||||
}
|
||||
return manifest({
|
||||
migrationId,
|
||||
source: {
|
||||
kind: 'local-wsl',
|
||||
distro: 'Ubuntu',
|
||||
scopeRepoIds: [repoId],
|
||||
scopeFolderWorkspaceIds: [],
|
||||
generation: GENERATION
|
||||
},
|
||||
payload: { repositories: [repo], projectGroups: [], folderWorkspaces: [] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('two WSL moves that share a generation number', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-wsl-generation-reuse-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('stage and commit independently, each under its own receipt', async () => {
|
||||
const store = createStore()
|
||||
const earlier = wslMove('move-earlier', 'repo-a')
|
||||
const later = wslMove('move-later', 'repo-b')
|
||||
expect(earlier.manifestSha256).not.toBe(later.manifestSha256)
|
||||
|
||||
expect(store.stageOrcadMigrationCatalog(earlier).state).toBe('staged')
|
||||
expect(store.stageOrcadMigrationCatalog(later).state).toBe('staged')
|
||||
expect(store.commitStagedOrcadMigrationCatalog(earlier)).toMatchObject({
|
||||
state: 'committed',
|
||||
receipt: { migrationId: 'move-earlier', repositoryIds: ['repo-a'] }
|
||||
})
|
||||
// The earlier commit neither committed nor dropped the later stage.
|
||||
expect(store.getOrcadMigrationCatalogState(later).state).toBe('staged')
|
||||
expect(store.commitStagedOrcadMigrationCatalog(later)).toMatchObject({
|
||||
state: 'committed',
|
||||
receipt: { migrationId: 'move-later', repositoryIds: ['repo-b'] }
|
||||
})
|
||||
await store.flushPendingOrThrowAsync()
|
||||
|
||||
const restored = createStore()
|
||||
for (const move of [earlier, later]) {
|
||||
expect(restored.getOrcadMigrationCatalogState(move)).toMatchObject({
|
||||
state: 'committed',
|
||||
migrationId: move.migrationId,
|
||||
manifestSha256: move.manifestSha256
|
||||
})
|
||||
}
|
||||
expect(
|
||||
restored
|
||||
.getRepos()
|
||||
.map((repo) => repo.id)
|
||||
.sort()
|
||||
).toEqual(['repo-a', 'repo-b'])
|
||||
})
|
||||
|
||||
it('abort one without touching the other', () => {
|
||||
const store = createStore()
|
||||
const earlier = wslMove('move-earlier', 'repo-a')
|
||||
const later = wslMove('move-later', 'repo-b')
|
||||
store.stageOrcadMigrationCatalog(earlier)
|
||||
store.stageOrcadMigrationCatalog(later)
|
||||
|
||||
expect(store.abortStagedOrcadMigrationCatalog(later)).toMatchObject({ aborted: true })
|
||||
expect(store.getOrcadMigrationCatalogState(earlier).state).toBe('staged')
|
||||
expect(store.commitStagedOrcadMigrationCatalog(earlier).state).toBe('committed')
|
||||
expect(store.getOrcadMigrationCatalogState(later).state).toBe('absent')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../../../shared/orcad-migration/orcad-migration-manifest-validation'
|
||||
import type { OrcadMigrationDormantStatePayload } from '../../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import {
|
||||
wslMoveJournalKey,
|
||||
type WslMoveCutover,
|
||||
@@ -10,7 +11,9 @@ export function wslMoveCutoverFixture(
|
||||
migrationId = 'move-1',
|
||||
phase: WslMoveCutoverPhase = 'prepared',
|
||||
extra: Partial<WslMoveCutover> = {},
|
||||
scope: { repoIds?: string[]; folderWorkspaceIds?: string[] } = {}
|
||||
scope: { repoIds?: string[]; folderWorkspaceIds?: string[] } = {},
|
||||
dormantState?: OrcadMigrationDormantStatePayload,
|
||||
distro = 'Ubuntu'
|
||||
): WslMoveCutover {
|
||||
const repoIds = scope.repoIds ?? ['repo-1']
|
||||
const folderWorkspaceIds = scope.folderWorkspaceIds ?? []
|
||||
@@ -20,7 +23,7 @@ export function wslMoveCutoverFixture(
|
||||
createdAt: '2026-10-13T00:00:00.000Z',
|
||||
source: {
|
||||
kind: 'local-wsl' as const,
|
||||
distro: 'Ubuntu',
|
||||
distro,
|
||||
scopeRepoIds: repoIds,
|
||||
scopeFolderWorkspaceIds: folderWorkspaceIds,
|
||||
generation: 1
|
||||
@@ -48,9 +51,10 @@ export function wslMoveCutoverFixture(
|
||||
lastActivityAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}))
|
||||
})),
|
||||
...(dormantState ? { dormantState } : {})
|
||||
},
|
||||
destinationEnvironmentId: 'wsl:Ubuntu'
|
||||
destinationEnvironmentId: `wsl:${distro}`
|
||||
}
|
||||
const manifest = { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) }
|
||||
return {
|
||||
@@ -59,9 +63,9 @@ export function wslMoveCutoverFixture(
|
||||
phase,
|
||||
startedAt: '2026-10-13T00:00:00.000Z',
|
||||
updatedAt: '2026-10-13T00:00:00.000Z',
|
||||
destinationEnvironmentId: 'wsl:Ubuntu',
|
||||
destinationName: 'Ubuntu',
|
||||
journalKey: wslMoveJournalKey('Ubuntu'),
|
||||
destinationEnvironmentId: `wsl:${distro}`,
|
||||
destinationName: distro,
|
||||
journalKey: wslMoveJournalKey(distro),
|
||||
manifestSha256: manifest.manifestSha256,
|
||||
provenExitedSessionIds: [],
|
||||
rewrittenStores: [],
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* A fake world for the WSL move driver: a profile with one WSL project, a WSL host whose catalog,
|
||||
* blob staging and commit can fail at named points (the fault-injection pattern of #29826), and
|
||||
* terminals that pause on request. The journal is the real one, in a temp directory.
|
||||
*/
|
||||
import type {
|
||||
OrcadMigrationBlobDescriptor,
|
||||
OrcadMigrationBlobUploadState
|
||||
} from '../../../../shared/orcad-migration/orcad-migration-blob-transfer'
|
||||
import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../../shared/orcad-migration/orcad-migration-blob-transfer'
|
||||
import type {
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationManifest
|
||||
} from '../../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import {
|
||||
ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/orcad-runtime-capabilities'
|
||||
import { getDefaultPersistedState } from '../../../../shared/constants'
|
||||
import { terminalTab } from './wsl-mover-profile-rows'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { WslMoveProgressEvent } from '../../../../shared/wsl-move/wsl-move-driver-types'
|
||||
import { collectOrcadMigrationSourceDormantState } from '../../migrating-orcad-catalog/orcad-source-dormant-state'
|
||||
import { exportWslMoveManifest } from '../../moving-wsl-projects/wsl-move-manifest-export'
|
||||
import { runWslMoveDesktopRewritePass } from '../../moving-wsl-projects/wsl-move-desktop-rewrite'
|
||||
import {
|
||||
readWslMoveSleptScrollbackFromHistory,
|
||||
wslMoveSleptSpoolRoot
|
||||
} from '../../moving-wsl-projects/wsl-move-slept-scrollback'
|
||||
import type {
|
||||
WslMoveDriverDeps,
|
||||
WslMoveHost,
|
||||
WslMoveManifestExport
|
||||
} from '../../moving-wsl-projects/wsl-move-driver-ports'
|
||||
import type { WslMoveGateInputs } from '../../moving-wsl-projects/wsl-move-terminal-gate'
|
||||
|
||||
export class WslMoveWorldCrash extends Error {}
|
||||
|
||||
export const WSL_MOVE_WORLD_PANE = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
|
||||
export type WslMoveWorldState = {
|
||||
/** Where the next failure fires; each fires once. */
|
||||
failAt: Set<string>
|
||||
points: string[]
|
||||
catalog: Map<string, 'staged' | 'committed'>
|
||||
blobUploads: Map<string, OrcadMigrationBlobUploadState>
|
||||
committedWithBlobs: OrcadMigrationBlobDescriptor[] | null
|
||||
progress: WslMoveProgressEvent[]
|
||||
slept: string[][]
|
||||
gate: WslMoveGateInputs
|
||||
/** Overrides the export, e.g. to grow it after Sleep. */
|
||||
exportOverride: ((exported: WslMoveManifestExport, call: number) => WslMoveManifestExport) | null
|
||||
repos: Repo[]
|
||||
}
|
||||
|
||||
export type WslMoveWorld = WslMoveWorldState & { deps: WslMoveDriverDeps }
|
||||
|
||||
const unc = (linux: string): string => `\\\\wsl.localhost\\Ubuntu${linux.replaceAll('/', '\\')}`
|
||||
|
||||
export function createWslMoveWorld(userDataPath: string): WslMoveWorld {
|
||||
const world: WslMoveWorldState = {
|
||||
failAt: new Set(),
|
||||
points: [],
|
||||
catalog: new Map(),
|
||||
blobUploads: new Map(),
|
||||
committedWithBlobs: null,
|
||||
progress: [],
|
||||
slept: [],
|
||||
gate: { panes: [], runningAutomationRunIds: [], activeDispatchIds: [] },
|
||||
exportOverride: null,
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: unc('/home/dev/app'),
|
||||
displayName: 'app',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
const point = (name: string): void => {
|
||||
world.points.push(name)
|
||||
if (world.failAt.delete(name)) {
|
||||
throw new WslMoveWorldCrash(name)
|
||||
}
|
||||
}
|
||||
const state = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => {
|
||||
const ids = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 }
|
||||
const held = world.catalog.get(manifest.migrationId)
|
||||
if (held === 'committed') {
|
||||
return {
|
||||
state: 'committed',
|
||||
...ids,
|
||||
receipt: {
|
||||
version: manifest.version,
|
||||
...ids,
|
||||
source: manifest.source,
|
||||
importedAt: '2026-10-20T00:00:00.000Z',
|
||||
repositoryIds: manifest.payload.repositories.map((repo) => repo.id),
|
||||
projectGroupIds: [],
|
||||
folderWorkspaceIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
if (held === 'staged') {
|
||||
return {
|
||||
state: 'staged',
|
||||
...ids,
|
||||
stagedAt: '2026-10-20T00:00:00.000Z',
|
||||
blobUploads: [...world.blobUploads.values()]
|
||||
}
|
||||
}
|
||||
return { state: 'absent', ...ids }
|
||||
}
|
||||
const commit = async (manifest: OrcadMigrationManifest): Promise<OrcadMigrationCatalogState> => {
|
||||
point('commit:before')
|
||||
if (!world.catalog.has(manifest.migrationId)) {
|
||||
throw new Error('orcad_migration_catalog_not_staged')
|
||||
}
|
||||
world.catalog.set(manifest.migrationId, 'committed')
|
||||
point('commit:lost-reply')
|
||||
return state(manifest)
|
||||
}
|
||||
const host: WslMoveHost = {
|
||||
// The journal fixture's spelling, so a seeded earlier move names this same host.
|
||||
environmentId: 'wsl:Ubuntu',
|
||||
name: 'Ubuntu',
|
||||
capabilities: [
|
||||
ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY,
|
||||
...Object.values(ORCAD_MIGRATION_BLOB_KIND_CONTRACTS).map((contract) => contract.capability)
|
||||
],
|
||||
catalog: {
|
||||
readState: async (manifest) => state(manifest),
|
||||
stage: async (manifest) => {
|
||||
point('stage:before')
|
||||
if (!world.catalog.has(manifest.migrationId)) {
|
||||
world.catalog.set(manifest.migrationId, 'staged')
|
||||
}
|
||||
point('stage:lost-reply')
|
||||
return state(manifest)
|
||||
},
|
||||
commit,
|
||||
abort: async (manifest) => {
|
||||
const aborted = world.catalog.get(manifest.migrationId) === 'staged'
|
||||
if (aborted) {
|
||||
world.catalog.delete(manifest.migrationId)
|
||||
world.blobUploads.clear()
|
||||
}
|
||||
return { ...state(manifest), aborted, durableAbsent: true }
|
||||
},
|
||||
abortByReference: async () => {
|
||||
throw new Error('unused')
|
||||
},
|
||||
stageChunk: async () => {
|
||||
throw new Error('unused: the fixture carries no scrollback snapshots')
|
||||
}
|
||||
},
|
||||
commitWithBlobs: async (manifest, expectedBlobs) => {
|
||||
world.committedWithBlobs = [...expectedBlobs]
|
||||
return commit(manifest)
|
||||
},
|
||||
blobs: {
|
||||
stageChunk: async (request) => {
|
||||
point(`blob:${request.blob.blobKind}`)
|
||||
const bytes = Buffer.from(request.bytesBase64, 'base64').length
|
||||
const received = request.offset + bytes
|
||||
world.blobUploads.set(request.blob.key, { ...request.blob, receivedBytes: received })
|
||||
return {
|
||||
migrationId: request.migrationId,
|
||||
manifestSha256: request.manifestSha256,
|
||||
blobKind: request.blob.blobKind,
|
||||
key: request.blob.key,
|
||||
receivedBytes: received
|
||||
}
|
||||
}
|
||||
},
|
||||
readHolding: async () => ({ repoIds: [], folderWorkspaceIds: [], highestGeneration: 0 })
|
||||
}
|
||||
let exports = 0
|
||||
let migrations = 0
|
||||
const deps: WslMoveDriverDeps = {
|
||||
userDataPath,
|
||||
readCatalog: () => ({ repos: world.repos, folderWorkspaces: [], projectGroups: [] }),
|
||||
connectHost: async () => host,
|
||||
isDistroRegistered: async () => true,
|
||||
exportManifest: (request) => {
|
||||
exports += 1
|
||||
const state = getDefaultPersistedState('C:\\Users\\dev')
|
||||
state.repos = world.repos
|
||||
// The paused pane's tab, so its terminal-history blob has a pane on the host to restore into.
|
||||
const [tabId, leafId] = WSL_MOVE_WORLD_PANE.split(':')
|
||||
const worktreeId = `repo-1::${unc('/home/dev/app')}`
|
||||
state.workspaceSession = {
|
||||
...state.workspaceSession,
|
||||
tabsByWorktree: { [worktreeId]: [terminalTab(tabId, worktreeId, null, null)] },
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: { root: { type: 'leaf', leafId }, activeLeafId: leafId, expandedLeafId: null }
|
||||
}
|
||||
}
|
||||
const exported = exportWslMoveManifest(
|
||||
{
|
||||
getRepos: () => world.repos,
|
||||
getFolderWorkspaces: () => [],
|
||||
getProjectGroups: () => [],
|
||||
inspectOrcadMigrationSourceDormantState: (source, catalog, destination) =>
|
||||
collectOrcadMigrationSourceDormantState(state, source, catalog, undefined, destination)
|
||||
},
|
||||
request
|
||||
)
|
||||
return world.exportOverride ? world.exportOverride(exported, exports) : exported
|
||||
},
|
||||
gateInputs: async () => world.gate,
|
||||
listPanes: () => [{ paneKey: WSL_MOVE_WORLD_PANE, sessionId: 'session-1', wslDistro: null }],
|
||||
quiesce: {
|
||||
sleepWorkspaces: async (ids) => {
|
||||
world.slept.push([...ids])
|
||||
return { failedWorktreeIds: [] }
|
||||
},
|
||||
livePtyIds: () => [],
|
||||
captureRestoreHints: async () => ({
|
||||
hints: [{ paneKey: WSL_MOVE_WORLD_PANE, cwd: '/home/dev/app/src' }],
|
||||
unreadablePaneKeys: [],
|
||||
noCwdPaneKeys: [],
|
||||
staleCwdPaneKeys: []
|
||||
}),
|
||||
proveExited: async () => {
|
||||
point('prove-exited')
|
||||
return { verdict: 'exited' }
|
||||
}
|
||||
},
|
||||
// The paused pane's checkpoint, carried as its terminal-history blob.
|
||||
sleptScrollback: (panes, distro, migrationId) =>
|
||||
readWslMoveSleptScrollbackFromHistory(
|
||||
{
|
||||
detectColdRestoreState: async (sessionId) => ({
|
||||
status: 'restored',
|
||||
sessionId,
|
||||
hasUnreadableRecovery: false,
|
||||
restoreInfo: {
|
||||
snapshotAnsi: '$ make\r\nok\r\n$ ',
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: '',
|
||||
cwd: '/home/dev/app/src',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
scrollbackLines: 2,
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
panes.map((pane) => ({ ...pane, wslDistro: distro })),
|
||||
distro,
|
||||
wslMoveSleptSpoolRoot(userDataPath, migrationId),
|
||||
() => null,
|
||||
() => new Date('2026-10-20T00:00:00.000Z')
|
||||
),
|
||||
snapshots: {
|
||||
readOrcadMigrationSourceSnapshotChunk: () => {
|
||||
throw new Error('unused')
|
||||
}
|
||||
},
|
||||
shellHistory: async () => ({
|
||||
descriptors: [],
|
||||
source: {
|
||||
readChunk: async () => {
|
||||
throw new Error('unused')
|
||||
}
|
||||
}
|
||||
}),
|
||||
finishOnDesktop: () => runWslMoveDesktopRewritePass({ userDataPath, warn: () => {} }),
|
||||
progress: (event) => world.progress.push(event),
|
||||
newMigrationId: () => `move-${(migrations += 1)}`,
|
||||
now: () => new Date('2026-10-20T00:00:00.000Z')
|
||||
}
|
||||
return Object.assign(world, { deps })
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* One desktop moving one project into a WSL host backed by a real orcad profile store, with every
|
||||
* step a kill point (wsl-move-store-host). Shared by the fault, update, lost-distro and
|
||||
* two-desktop tests. A test file mocks the journal itself and points its hook at `rig.at`.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { wslMoveJournalKey } from '../../../../shared/wsl-move/wsl-move-cutover'
|
||||
import { createStore, testState } from '../../persistence-test-harness'
|
||||
import type { Store } from '../../loading-store/store'
|
||||
import {
|
||||
listWslMoveCutoverChain,
|
||||
listWslMoveCutovers
|
||||
} from '../../moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import { runWslMoveDesktopRewritePass } from '../../moving-wsl-projects/wsl-move-desktop-rewrite'
|
||||
import {
|
||||
registerWslMoveDesktopRewriteStore,
|
||||
resetWslMoveDesktopRewriteStoresForTests,
|
||||
setWslMoveDesktopRewriteRegistryCompleteForTests
|
||||
} from '../../moving-wsl-projects/wsl-move-desktop-rewrite-stores'
|
||||
import { resolveWslMoveFence } from '../../moving-wsl-projects/wsl-move-fence'
|
||||
import {
|
||||
listWslMoveOwedHostAborts,
|
||||
settleWslMoveOwedHostAborts
|
||||
} from '../../moving-wsl-projects/wsl-move-owed-host-aborts'
|
||||
import {
|
||||
recoverInterruptedWslMoves,
|
||||
resetWslMoveToZero
|
||||
} from '../../moving-wsl-projects/wsl-move-reset'
|
||||
import { runWslMoveRetentionPass } from '../../moving-wsl-projects/wsl-move-retention'
|
||||
import { createWslMoveWorld, type WslMoveWorld } from './wsl-move-driver-world'
|
||||
import {
|
||||
createWslMoveKillSwitch,
|
||||
createWslMoveStoreHost,
|
||||
passWslMoveKillPoint,
|
||||
type WslMoveKillSwitch
|
||||
} from './wsl-move-store-host'
|
||||
|
||||
export const WSL_MOVE_RIG_STORE_IDS = ['main-profile', 'renderer-ui', 'browser'] as const
|
||||
export const WSL_MOVE_RIG_REQUEST = { distro: 'Ubuntu' }
|
||||
|
||||
/** `zero-abort-owed`: the desktop is at zero; the host's leftover stage goes on its next contact. */
|
||||
export type WslMoveRigOutcome =
|
||||
| 'back-at-zero'
|
||||
| 'zero-abort-owed'
|
||||
| 'fully-moved'
|
||||
| 'waiting-for-host'
|
||||
| 'stuck'
|
||||
|
||||
export type WslMoveRig = {
|
||||
userDataPath: string
|
||||
kill: WslMoveKillSwitch
|
||||
world: WslMoveWorld
|
||||
/** The host's current store; an orcad restart replaces it. */
|
||||
host: Store
|
||||
/** False while the distro is unregistered: the host neither connects nor answers. */
|
||||
hostPresent: boolean
|
||||
rewritten: Map<string, number>
|
||||
at: (name: string) => void
|
||||
}
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
export function wslMoveRigTempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix))
|
||||
dirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
export function removeWslMoveRigDirs(): void {
|
||||
for (const dir of dirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh profile and host, with this rig's rewrite stores as the only registered ones. */
|
||||
export function createWslMoveKillRig(): WslMoveRig {
|
||||
resetWslMoveDesktopRewriteStoresForTests()
|
||||
setWslMoveDesktopRewriteRegistryCompleteForTests(true)
|
||||
testState.dir = wslMoveRigTempDir('wsl-move-host-')
|
||||
const userDataPath = wslMoveRigTempDir('wsl-move-desktop-')
|
||||
const kill = createWslMoveKillSwitch()
|
||||
const world = createWslMoveWorld(userDataPath)
|
||||
const at = (name: string): void => passWslMoveKillPoint(kill, name)
|
||||
const rig: WslMoveRig = {
|
||||
userDataPath,
|
||||
kill,
|
||||
world,
|
||||
host: createStore(),
|
||||
hostPresent: true,
|
||||
rewritten: new Map(),
|
||||
at
|
||||
}
|
||||
const host = createWslMoveStoreHost({
|
||||
store: () => rig.host,
|
||||
kill,
|
||||
available: () => rig.hostPresent
|
||||
})
|
||||
world.deps.connectHost = async () => {
|
||||
at('connect')
|
||||
return rig.hostPresent ? host : null
|
||||
}
|
||||
world.deps.isDistroRegistered = async () => rig.hostPresent
|
||||
const { quiesce } = world.deps
|
||||
world.deps.quiesce = {
|
||||
sleepWorkspaces: async (ids) => (at('sleep'), quiesce.sleepWorkspaces(ids)),
|
||||
livePtyIds: quiesce.livePtyIds,
|
||||
captureRestoreHints: async (panes, distro) => (
|
||||
at('restore-hints'),
|
||||
quiesce.captureRestoreHints(panes, distro)
|
||||
),
|
||||
proveExited: async (scope) => (at('prove-exited'), quiesce.proveExited(scope))
|
||||
}
|
||||
for (const id of WSL_MOVE_RIG_STORE_IDS) {
|
||||
registerWslMoveDesktopRewriteStore({
|
||||
id,
|
||||
side: 'main',
|
||||
run: () => {
|
||||
at(`store:${id}:run`)
|
||||
rig.rewritten.set(id, 1)
|
||||
},
|
||||
flush: () => at(`store:${id}:flush`)
|
||||
})
|
||||
}
|
||||
return rig
|
||||
}
|
||||
|
||||
export function wslMoveRigRetentionStore(rig: WslMoveRig) {
|
||||
return {
|
||||
getRepos: () => rig.world.repos,
|
||||
getFolderWorkspaces: () => [],
|
||||
setWslMoveRetainedMarks: () => 1,
|
||||
flushPendingOrThrowAsync: async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
/** orcad stopped and started again over the same disk, as an update or a distro restart does. */
|
||||
export async function restartWslMoveRigHost(rig: WslMoveRig): Promise<void> {
|
||||
await rig.host.flushPendingOrThrowAsync().catch(() => {})
|
||||
rig.host = createStore()
|
||||
}
|
||||
|
||||
/** Startup, then the user's next move attempt reaching the host (which settles the old one). */
|
||||
export async function restartWslMoveRig(
|
||||
rig: WslMoveRig,
|
||||
options: { hostUp: boolean; orcadRestarted: boolean }
|
||||
): Promise<void> {
|
||||
rig.kill.dead = false
|
||||
if (options.orcadRestarted) {
|
||||
await restartWslMoveRigHost(rig)
|
||||
}
|
||||
await recoverInterruptedWslMoves(rig.userDataPath, () => {}, rig.world.deps.isDistroRegistered)
|
||||
await runWslMoveDesktopRewritePass({ userDataPath: rig.userDataPath, warn: () => {} })
|
||||
if (options.hostUp) {
|
||||
// What the next move's prepare does once it reaches the host, before it stages anything.
|
||||
const key = wslMoveJournalKey(WSL_MOVE_RIG_REQUEST.distro)
|
||||
const host = await rig.world.deps.connectHost(WSL_MOVE_RIG_REQUEST.distro)
|
||||
if (host) {
|
||||
await settleWslMoveOwedHostAborts(rig.userDataPath, key, host.catalog)
|
||||
}
|
||||
const head = listWslMoveCutoverChain(rig.userDataPath, key).at(-1)
|
||||
if (head) {
|
||||
await resetWslMoveToZero(
|
||||
{ userDataPath: rig.userDataPath, destination: host?.catalog ?? null },
|
||||
head.migrationId
|
||||
)
|
||||
await rig.world.deps.finishOnDesktop()
|
||||
}
|
||||
}
|
||||
await runWslMoveRetentionPass({
|
||||
userDataPath: rig.userDataPath,
|
||||
store: wslMoveRigRetentionStore(rig)
|
||||
})
|
||||
}
|
||||
|
||||
export function wslMoveRigOutcome(rig: WslMoveRig): WslMoveRigOutcome {
|
||||
const records = listWslMoveCutovers(rig.userDataPath)
|
||||
const fence = resolveWslMoveFence(rig.userDataPath)
|
||||
const onHost = rig.host.getRepos().some((repo) => repo.id === 'repo-1')
|
||||
if (records.length === 0) {
|
||||
const staged = rig.host.hasStagedOrcadMigrationCatalog()
|
||||
const owed = listWslMoveOwedHostAborts(rig.userDataPath).length > 0
|
||||
const clean =
|
||||
!onHost &&
|
||||
(!staged || owed) &&
|
||||
fence.fenced.repoIds.size === 0 &&
|
||||
fence.hidden.repoIds.size === 0 &&
|
||||
fence.sessionFrozen.repoIds.size === 0 &&
|
||||
rig.rewritten.size === 0
|
||||
if (!clean) {
|
||||
return 'stuck'
|
||||
}
|
||||
return owed ? 'zero-abort-owed' : 'back-at-zero'
|
||||
}
|
||||
const [record] = records
|
||||
if (records.length === 1 && record.phase === 'retained') {
|
||||
const allOnce = WSL_MOVE_RIG_STORE_IDS.every((id) => rig.rewritten.get(id) === 1)
|
||||
return allOnce &&
|
||||
onHost &&
|
||||
fence.hidden.repoIds.has('repo-1') &&
|
||||
fence.fenced.repoIds.size === 0
|
||||
? 'fully-moved'
|
||||
: 'stuck'
|
||||
}
|
||||
if (records.length === 1 && record.phase === 'blobs-transferred') {
|
||||
return 'waiting-for-host'
|
||||
}
|
||||
return 'stuck'
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* What the real round trip may legitimately change, so the comparison catches everything else:
|
||||
* schema defaults the host's load fills in, ids it derives, and representation changes the move
|
||||
* makes on purpose. Each known gap is a real loss, named here so a fix has to remove it.
|
||||
*/
|
||||
import { isRecord } from '../../moving-wsl-projects/wsl-move-canonical-form'
|
||||
|
||||
/** Session fields the real pipeline does not carry yet, with why; each still differs today. */
|
||||
export const WSL_MOVE_ROUND_TRIP_KNOWN_GAPS: Readonly<Record<string, string>> = {
|
||||
activeWorktreeIdsOnShutdown: 'Never seeded: focus belongs to whoever looks at the host.',
|
||||
terminalSurfaceTombstonesByPaneKey: 'Fences of local PTY incarnations; the host starts its own.'
|
||||
}
|
||||
|
||||
// Row keys the host's load fills in; dropped only where the source row left the key out.
|
||||
const FILLED_IN: Readonly<Record<string, readonly string[]>> = {
|
||||
repos: ['gitUsername', 'hookSettings', 'kind'],
|
||||
projects: ['kind'],
|
||||
// Re-derived on the host from the project the setup resolves to.
|
||||
worktreeMeta: ['projectId']
|
||||
}
|
||||
// Moved disabled; the ledgered `host-automations` step turns them back on (checked separately).
|
||||
const BOTH_SIDES: Readonly<Record<string, readonly string[]>> = { automations: ['enabled'] }
|
||||
const DEFAULTED_SESSION: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
|
||||
browserTabsByWorktree: { sessionPartition: null, sessionProfileId: null },
|
||||
sleepingAgentSessionsByPaneKey: { connectionId: null }
|
||||
}
|
||||
|
||||
type Rows = Map<string, Record<string, unknown>>
|
||||
|
||||
/** A field's rows by id (arrays) or by key (records). */
|
||||
function rowsOf(value: unknown): Rows {
|
||||
const rows: Rows = new Map()
|
||||
const entries = Array.isArray(value)
|
||||
? value.map((row): [unknown, unknown] => [isRecord(row) ? row.id : undefined, row])
|
||||
: Object.entries(isRecord(value) ? value : {})
|
||||
for (const [id, row] of entries) {
|
||||
if (typeof id === 'string' && isRecord(row)) {
|
||||
rows.set(id, row)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Top-level row keys in `keys` dropped where `drop` says so; nested values are never touched. */
|
||||
function withoutRowKeys(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
drop: (id: string, key: string) => boolean
|
||||
): unknown {
|
||||
const next: unknown = structuredClone(value ?? null)
|
||||
for (const [id, row] of rowsOf(next)) {
|
||||
for (const key of keys) {
|
||||
if (drop(id, key)) {
|
||||
delete row[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function withoutDefaultValues(
|
||||
value: unknown,
|
||||
defaults: Readonly<Record<string, unknown>>
|
||||
): unknown {
|
||||
return JSON.parse(JSON.stringify(value ?? null), (key, entry: unknown) =>
|
||||
Object.hasOwn(defaults, key) && entry === defaults[key] ? undefined : entry
|
||||
)
|
||||
}
|
||||
|
||||
/** Empty per-repo lists are the same as none. */
|
||||
function withoutEmptyLists(value: unknown): unknown {
|
||||
if (!isRecord(value)) {
|
||||
return value
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, entry]) => !(Array.isArray(entry) && entry.length === 0))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-only scrollback travels as a host scrollback snapshot. Returns the destination session
|
||||
* with exactly those snapshot refs removed, or null when a parked leaf did not become one.
|
||||
*/
|
||||
export function withoutParkedScrollbackRefs(
|
||||
sourceSession: unknown,
|
||||
destinationSession: unknown
|
||||
): Record<string, unknown> | null {
|
||||
const parked = isRecord(sourceSession) ? sourceSession.localOnlyScrollbackByTabId : undefined
|
||||
const next = structuredClone(isRecord(destinationSession) ? destinationSession : {})
|
||||
const layouts = next.terminalLayoutsByTabId
|
||||
for (const [tabId, leaves] of Object.entries(isRecord(parked) ? parked : {})) {
|
||||
const layout = isRecord(layouts) ? layouts[tabId] : undefined
|
||||
const refs = isRecord(layout) ? layout.scrollbackRefsByLeafId : undefined
|
||||
if (!isRecord(layout) || !isRecord(refs) || !isRecord(leaves)) {
|
||||
return null
|
||||
}
|
||||
for (const leafId of Object.keys(leaves)) {
|
||||
if (typeof refs[leafId] !== 'string') {
|
||||
return null
|
||||
}
|
||||
delete refs[leafId]
|
||||
}
|
||||
if (Object.keys(refs).length === 0) {
|
||||
delete layout.scrollbackRefsByLeafId
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* One projection with every allowed difference taken out. `source` is the source projection when
|
||||
* normalizing the destination: a filled-in key goes only where that source row lacked it.
|
||||
*/
|
||||
export function normalizeWslMoveRoundTripProjection(
|
||||
projection: unknown,
|
||||
source?: unknown
|
||||
): unknown {
|
||||
if (!isRecord(projection)) {
|
||||
return projection
|
||||
}
|
||||
const next: Record<string, unknown> = {}
|
||||
for (const [field, value] of Object.entries(projection)) {
|
||||
if (field === 'session') {
|
||||
continue
|
||||
}
|
||||
const sourceRows = rowsOf(isRecord(source) ? source[field] : undefined)
|
||||
const filledIn = FILLED_IN[field]
|
||||
const bothSides = BOTH_SIDES[field]
|
||||
next[field] =
|
||||
field === 'sparsePresetsByRepo'
|
||||
? withoutEmptyLists(value)
|
||||
: bothSides
|
||||
? withoutRowKeys(value, bothSides, () => true)
|
||||
: filledIn && source !== undefined
|
||||
? withoutRowKeys(
|
||||
value,
|
||||
filledIn,
|
||||
(id, key) => !Object.hasOwn(sourceRows.get(id) ?? {}, key)
|
||||
)
|
||||
: value
|
||||
}
|
||||
const session = isRecord(projection.session) ? projection.session : {}
|
||||
const nextSession: Record<string, unknown> = {}
|
||||
for (const [field, value] of Object.entries(session)) {
|
||||
if (
|
||||
Object.hasOwn(WSL_MOVE_ROUND_TRIP_KNOWN_GAPS, field) ||
|
||||
field === 'localOnlyScrollbackByTabId'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// An empty record is the same as none.
|
||||
if (isRecord(value) && Object.keys(value).length === 0) {
|
||||
continue
|
||||
}
|
||||
const defaults = DEFAULTED_SESSION[field]
|
||||
nextSession[field] = defaults ? withoutDefaultValues(value, defaults) : value
|
||||
}
|
||||
next.session = nextSession
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Real profile stores for a desktop and its WSL host side by side in one test: each binds its own
|
||||
* data file at creation, so the shared test directory only has to point at it while it opens.
|
||||
*/
|
||||
import { join } from 'node:path'
|
||||
import type { PersistedState } from '../../../../shared/persisted-state-types'
|
||||
import {
|
||||
createStore,
|
||||
readPersistedStateJson,
|
||||
testState,
|
||||
writeDataFile
|
||||
} from '../../persistence-test-harness'
|
||||
import type { Store } from '../../loading-store/store'
|
||||
|
||||
export function openWslMoveRoundTripStore(dir: string, seed?: PersistedState): Store {
|
||||
testState.dir = dir
|
||||
if (seed) {
|
||||
writeDataFile(seed)
|
||||
}
|
||||
return createStore()
|
||||
}
|
||||
|
||||
/** The persisted JSON of the store opened over `dir`, as a reader of its profile gets it. */
|
||||
export function readWslMoveRoundTripStoreJson(dir: string): string {
|
||||
return readPersistedStateJson(join(dir, 'orca-data.json'))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* A WSL host for the move driver backed by a real orcad profile store, so staging claims, blob
|
||||
* staging, the commit's blob check and receipts are the production code. Every mutation passes a
|
||||
* kill point before it lands and after it is durable (a lost reply). A kill stops the whole desktop
|
||||
* process: from then on every call throws, so no catch handler can tidy up what a crash would leave.
|
||||
*/
|
||||
import type {
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationManifest
|
||||
} from '../../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../../shared/orcad-migration/orcad-migration-blob-transfer'
|
||||
import {
|
||||
ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/orcad-runtime-capabilities'
|
||||
import type { Store } from '../../loading-store/store'
|
||||
import type { WslMoveHost } from '../../moving-wsl-projects/wsl-move-driver-ports'
|
||||
|
||||
export class WslMoveKilled extends Error {}
|
||||
|
||||
export type WslMoveKillSwitch = {
|
||||
/** The point the next kill fires at; null runs clean. */
|
||||
killAt: string | null
|
||||
/** True from the kill until the test restarts the process. */
|
||||
dead: boolean
|
||||
points: string[]
|
||||
/** Runs at each point before the kill check: something else happening mid-move. */
|
||||
onPoint?: (name: string) => void
|
||||
}
|
||||
|
||||
export function createWslMoveKillSwitch(): WslMoveKillSwitch {
|
||||
return { killAt: null, dead: false, points: [] }
|
||||
}
|
||||
|
||||
export function passWslMoveKillPoint(kill: WslMoveKillSwitch, name: string): void {
|
||||
if (kill.dead) {
|
||||
throw new WslMoveKilled(`dead at ${name}`)
|
||||
}
|
||||
kill.points.push(name)
|
||||
kill.onPoint?.(name)
|
||||
if (kill.killAt === name) {
|
||||
kill.killAt = null
|
||||
kill.dead = true
|
||||
throw new WslMoveKilled(name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every capability a current WSL host advertises for a move. */
|
||||
export const WSL_MOVE_STORE_HOST_CAPABILITIES: readonly string[] = [
|
||||
ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY,
|
||||
...Object.values(ORCAD_MIGRATION_BLOB_KIND_CONTRACTS).map((contract) => contract.capability)
|
||||
]
|
||||
|
||||
export type WslMoveStoreHostOptions = {
|
||||
/** The host's current store; an orcad restart swaps it for one reopened from disk. */
|
||||
store: () => Store
|
||||
kill: WslMoveKillSwitch
|
||||
/** Points named `<prefix>stage:before` etc., so two desktops on one host stay apart. */
|
||||
prefix?: string
|
||||
environmentId?: string
|
||||
name?: string
|
||||
/** False once the distro is unregistered: every call fails like a vanished host. */
|
||||
available?: () => boolean
|
||||
}
|
||||
|
||||
export function createWslMoveStoreHost(options: WslMoveStoreHostOptions): WslMoveHost {
|
||||
const { store, kill } = options
|
||||
const prefix = options.prefix ?? ''
|
||||
const at = (name: string): void => {
|
||||
passWslMoveKillPoint(kill, `${prefix}${name}`)
|
||||
if (options.available && !options.available()) {
|
||||
throw new Error('wsl_distro_not_registered')
|
||||
}
|
||||
}
|
||||
// Like the RPC layer: a mutation answers only once it is on disk.
|
||||
const mutate = async <T>(name: string, apply: () => T): Promise<T> => {
|
||||
at(`${name}:before`)
|
||||
const result = apply()
|
||||
await store().flushPendingOrThrowAsync()
|
||||
at(`${name}:lost-reply`)
|
||||
return result
|
||||
}
|
||||
const read = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => {
|
||||
at('read-state')
|
||||
return store().getOrcadMigrationCatalogState(manifest)
|
||||
}
|
||||
return {
|
||||
environmentId: options.environmentId ?? 'wsl:Ubuntu',
|
||||
name: options.name ?? 'Ubuntu',
|
||||
capabilities: WSL_MOVE_STORE_HOST_CAPABILITIES,
|
||||
catalog: {
|
||||
readState: async (manifest) => read(manifest),
|
||||
stage: (manifest) => mutate('stage', () => store().stageOrcadMigrationCatalog(manifest)),
|
||||
commit: (manifest) =>
|
||||
mutate('commit', () => store().commitStagedOrcadMigrationCatalog(manifest)),
|
||||
abort: async (manifest) => ({
|
||||
...(await mutate('abort', () => store().abortStagedOrcadMigrationCatalog(manifest))),
|
||||
durableAbsent: true
|
||||
}),
|
||||
abortByReference: (manifest) =>
|
||||
mutate('abort-by-reference', () =>
|
||||
store().abortStagedOrcadMigrationCatalogByReference({
|
||||
migrationId: manifest.migrationId,
|
||||
manifestSha256: manifest.manifestSha256
|
||||
})
|
||||
),
|
||||
stageChunk: (request) =>
|
||||
mutate('snapshot-chunk', () => store().stageOrcadMigrationSnapshotChunk(request))
|
||||
},
|
||||
commitWithBlobs: (manifest, expectedBlobs) =>
|
||||
mutate('commit', () =>
|
||||
store().commitStagedOrcadMigrationCatalog(manifest, { expectedBlobs })
|
||||
),
|
||||
blobs: {
|
||||
stageChunk: (request) =>
|
||||
mutate(`blob:${request.blob.blobKind}`, () => store().stageOrcadMigrationBlobChunk(request))
|
||||
},
|
||||
readHolding: async () => {
|
||||
at('read-holding')
|
||||
return {
|
||||
repoIds: store()
|
||||
.getRepos()
|
||||
.map((repo) => repo.id),
|
||||
folderWorkspaceIds: store()
|
||||
.getFolderWorkspaces()
|
||||
.map((folder) => folder.id),
|
||||
highestGeneration: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ export function foldersAndAutomationsWslMoverProfile(paths: WslMoverPaths): WslM
|
||||
kind: 'workspace-run' as const,
|
||||
projectId: 'project-auto',
|
||||
hostId: 'local' as const,
|
||||
projectHostSetupId: paths.side === 'source' ? 'setup-local' : 'setup-wsl',
|
||||
// A repo's own setup is keyed by the repo id on both sides (createSetupFromRepo, the export).
|
||||
projectHostSetupId: 'repo-auto',
|
||||
repoId: 'repo-auto',
|
||||
path: repoAuto.path
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ export function wslMoverProfileScope(
|
||||
repoIds: new Set(name === 'mixed-windows-wsl' ? ['repo-a'] : repoIds),
|
||||
folderWorkspaceIds: new Set(name === 'folders-and-automations' ? ['folder-1'] : []),
|
||||
ownerHostId: 'local',
|
||||
hostAliases: side === 'source' ? ['local'] : ['local', `runtime:wsl:${WSL_MOVER_DISTRO}`]
|
||||
hostAliases:
|
||||
side === 'source'
|
||||
? ['local']
|
||||
: ['local', `runtime:wsl:${WSL_MOVER_DISTRO}`, `wsl:${WSL_MOVER_DISTRO}`]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ import { removeWorkspaceLineageForFolderParent } from './metadata-lineage-operat
|
||||
import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation'
|
||||
import { dropOrcadTerminalRestoreHintsForWorktrees } from '../migrating-orcad-catalog/orcad-terminal-restore-hint-store'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
applyWslMoveRetainedMarks,
|
||||
type WslMoveRetainedMark
|
||||
} from '../moving-wsl-projects/wsl-move-retained-marks'
|
||||
|
||||
type ProjectCollectionOperationsRuntime = Pick<
|
||||
StoreRuntimeState,
|
||||
@@ -168,6 +172,16 @@ export class ProjectCollectionOperations {
|
||||
moveProjectToGroup(target: RepoHostTarget, groupId: string | null, order?: number): Repo | null {
|
||||
return getFolderWorkspaceOperations(this).moveProjectToGroup(target, groupId, order)
|
||||
}
|
||||
|
||||
/** The WSL mover's retention pass only; returns how many rows changed. */
|
||||
setWslMoveRetainedMarks(marks: readonly WslMoveRetainedMark[]): number {
|
||||
const { runtime, scheduling } = this[projectCollectionOperationsContext]
|
||||
const changed = applyWslMoveRetainedMarks(runtime.state, marks)
|
||||
if (changed > 0) {
|
||||
scheduleSave(scheduling)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectHostOperations(
|
||||
|
||||
@@ -14,6 +14,11 @@ import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-s
|
||||
import { withoutRedundantGlobalFields } from '../../../shared/workspace-session-host-field-ownership'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
|
||||
import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots'
|
||||
import {
|
||||
hasTerminalScrollbackSnapshotSync,
|
||||
readTerminalScrollbackSnapshot,
|
||||
statTerminalScrollbackSnapshotSize
|
||||
} from '../../terminal-scrollback-snapshot-nonblocking-reads'
|
||||
import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields'
|
||||
import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence'
|
||||
import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration'
|
||||
@@ -58,7 +63,11 @@ export class SessionHostPartitionOperations {
|
||||
scheduling: WriteSchedulingOperations,
|
||||
bindingRecovery: TerminalBindingRecoveryOperations
|
||||
) {
|
||||
this[sessionHostPartitionOperationsContext] = { runtime, scheduling, bindingRecovery }
|
||||
this[sessionHostPartitionOperationsContext] = {
|
||||
runtime,
|
||||
scheduling,
|
||||
bindingRecovery
|
||||
}
|
||||
}
|
||||
|
||||
getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] {
|
||||
@@ -112,6 +121,27 @@ export class SessionHostPartitionOperations {
|
||||
)
|
||||
}
|
||||
|
||||
hasTerminalScrollbackSnapshot(ref: string): boolean {
|
||||
return hasTerminalScrollbackSnapshotSync(
|
||||
ref,
|
||||
this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage
|
||||
)
|
||||
}
|
||||
|
||||
readTerminalScrollbackSnapshotAsync(ref: string): Promise<string | null> {
|
||||
return readTerminalScrollbackSnapshot(
|
||||
ref,
|
||||
this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage
|
||||
)
|
||||
}
|
||||
|
||||
statTerminalScrollbackSnapshotSize(ref: string): Promise<number | null> {
|
||||
return statTerminalScrollbackSnapshotSize(
|
||||
ref,
|
||||
this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage
|
||||
)
|
||||
}
|
||||
|
||||
getWorktreeIdForTab(tabId: string): string | undefined {
|
||||
return findWorktreeIdForTab(this.getWorkspaceSession(), tabId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMigrationJournalDirectory } from './migration-journal-directory'
|
||||
|
||||
type JournalRecord = { migrationId: string }
|
||||
|
||||
const journalOptions = {
|
||||
directoryName: 'journal',
|
||||
maxFileBytes: 1024,
|
||||
parse: (value: unknown): JournalRecord => {
|
||||
if (typeof value !== 'object' || value === null || !('migrationId' in value)) {
|
||||
throw new Error('not a record')
|
||||
}
|
||||
return { migrationId: String(value.migrationId) }
|
||||
},
|
||||
unreadable: (detail: string) => new Error(detail)
|
||||
}
|
||||
const journal = createMigrationJournalDirectory<JournalRecord>({
|
||||
...journalOptions,
|
||||
sweepOrphanedTemporaries: true
|
||||
})
|
||||
|
||||
// The sweep compares a file's mtime with the clock, so both read this fixed time.
|
||||
const NOW = Date.UTC(2026, 9, 24, 12)
|
||||
|
||||
let userDataPath: string
|
||||
let dir: string
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(NOW)
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'migration-journal-'))
|
||||
dir = join(userDataPath, 'journal')
|
||||
mkdirSync(dir)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function temporary(pid: number, ageMs: number): string {
|
||||
const path = join(dir, `move-1.json.${pid}.1792672615854.b6fca333.tmp`)
|
||||
writeFileSync(path, '{')
|
||||
const at = (NOW - ageMs) / 1000
|
||||
utimesSync(path, at, at)
|
||||
return path
|
||||
}
|
||||
|
||||
describe('migration journal directory', () => {
|
||||
it("removes another process's write that died before its rename", () => {
|
||||
// A killed journal write left `<id>.json.<pid>.<ts>.tmp` behind forever.
|
||||
const orphan = temporary(process.pid + 1, 120_000)
|
||||
expect(journal.list(userDataPath)).toEqual([])
|
||||
expect(existsSync(orphan)).toBe(false)
|
||||
})
|
||||
|
||||
it("keeps this process's own write and one another process may still be finishing", () => {
|
||||
const own = temporary(process.pid, 120_000)
|
||||
const recent = temporary(process.pid + 1, 0)
|
||||
expect(journal.list(userDataPath)).toEqual([])
|
||||
expect(existsSync(own)).toBe(true)
|
||||
expect(existsSync(recent)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves temporaries alone in a journal that does not sweep, as SSH migration journals do', () => {
|
||||
const orphan = temporary(process.pid + 1, 120_000)
|
||||
const unswept = createMigrationJournalDirectory<JournalRecord>(journalOptions)
|
||||
expect(unswept.list(userDataPath)).toEqual([])
|
||||
expect(existsSync(orphan)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,10 @@ import { readNodeFileSyncWithinLimit } from '../../../shared/node-bounded-file-r
|
||||
import { syncDirectoryDurablySync } from '../../durable-file-write'
|
||||
|
||||
const JOURNAL_FILE = /^[A-Za-z0-9_-]{1,128}\.json$/
|
||||
// `<id>.json.<pid>.<ms>.<rand>.tmp`: a durable write that died before its rename.
|
||||
const JOURNAL_TEMPORARY = /^[A-Za-z0-9_-]{1,128}\.json\.(\d+)\.\d+\.[0-9a-f]+\.tmp$/
|
||||
// Spares a write another instance may still be finishing.
|
||||
const TEMPORARY_SWEEP_MIN_AGE_MS = 60_000
|
||||
|
||||
export type MigrationJournalDirectory<T extends { migrationId: string }> = {
|
||||
directory: (userDataPath: string) => string
|
||||
@@ -23,9 +27,12 @@ export function createMigrationJournalDirectory<T extends { migrationId: string
|
||||
parse: (value: unknown) => T
|
||||
unreadable: (detail: string) => Error
|
||||
onChange?: (userDataPath: string) => void
|
||||
/** Remove other processes' writes that died before their rename (the WSL move journal only). */
|
||||
sweepOrphanedTemporaries?: boolean
|
||||
}): MigrationJournalDirectory<T> {
|
||||
// Frequent hidden-row checks would otherwise reparse full manifests.
|
||||
const parsed = new Map<string, { key: string; records: T[] }>()
|
||||
const swept = new Set<string>()
|
||||
const directory = (userDataPath: string): string => join(userDataPath, options.directoryName)
|
||||
const fileVersion = (path: string): string => {
|
||||
try {
|
||||
@@ -69,6 +76,10 @@ export function createMigrationJournalDirectory<T extends { migrationId: string
|
||||
} catch (error) {
|
||||
throw options.unreadable(errorMessage(error))
|
||||
}
|
||||
if (options.sweepOrphanedTemporaries && !swept.has(dir)) {
|
||||
swept.add(dir)
|
||||
removeOrphanedTemporaries(dir, names)
|
||||
}
|
||||
const files: { name: string; path: string }[] = []
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.json')) {
|
||||
@@ -125,6 +136,24 @@ export function createMigrationJournalDirectory<T extends { migrationId: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Once per directory and process: another process's write that never reached its rename. */
|
||||
function removeOrphanedTemporaries(dir: string, names: readonly string[]): void {
|
||||
for (const name of names) {
|
||||
const pid = JOURNAL_TEMPORARY.exec(name)?.[1]
|
||||
if (!pid || Number(pid) === process.pid) {
|
||||
continue
|
||||
}
|
||||
const path = join(dir, name)
|
||||
try {
|
||||
if (Date.now() - statSync(path).mtimeMs >= TEMPORARY_SWEEP_MIN_AGE_MS) {
|
||||
rmSync(path, { force: true })
|
||||
}
|
||||
} catch {
|
||||
// Gone already, or unreadable; it carries no journal state either way.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Oldest-first delta chain; forks or gaps fail closed. */
|
||||
export function orderMigrationJournalChain<
|
||||
T extends { migrationId: string; supersedesMigrationId?: string }
|
||||
|
||||
+13
@@ -2,6 +2,7 @@ import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../shared/orcad-migra
|
||||
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { getDaemonHistoryDir } from '../../daemon/daemon-launch-paths'
|
||||
import { getHistoryRoot } from '../../terminal-history-paths'
|
||||
import type { StoreRuntimeState } from '../loading-store/store-runtime-state'
|
||||
import {
|
||||
@@ -135,10 +136,12 @@ function installCommitted(
|
||||
root: runtime.orcadMigrationBlobRoot,
|
||||
terminalRestoreHints: runtime.orcadTerminalRestoreHints,
|
||||
shellHistoryRoot: getHistoryRoot(),
|
||||
terminalHistoryRoot: getDaemonHistoryDir(),
|
||||
paneShellPath: () => runtime.paneShellPath(runtime.state.settings?.terminalDefaultShell),
|
||||
worktreeHasLiveShells: options.worktreeHasLiveShells,
|
||||
deferredShellHistoryWorktreeIds: runtime.deferredShellHistoryWorktreeIds,
|
||||
paneWorktreeId: (paneKey) => findPaneWorktreeId(runtime.state, paneKey),
|
||||
workspacePath: (worktreeId) => findWorkspacePath(runtime.state, worktreeId),
|
||||
now: clock(options)
|
||||
})
|
||||
}
|
||||
@@ -147,6 +150,16 @@ function clock(options: ClockOptions): number {
|
||||
return (options.now ?? (() => new Date()))().getTime()
|
||||
}
|
||||
|
||||
// A git worktree id carries its path; a folder workspace's is on its record.
|
||||
function findWorkspacePath(state: PersistedState, worktreeId: string): string | null {
|
||||
const separator = worktreeId.indexOf('::')
|
||||
if (separator > 0) {
|
||||
return worktreeId.slice(separator + 2) || null
|
||||
}
|
||||
return state.folderWorkspaces.find((folder) => folder.id === worktreeId)?.folderPath ?? null
|
||||
}
|
||||
|
||||
// The commit merged the moved session in with its worktree ids unchanged.
|
||||
function findPaneWorktreeId(state: PersistedState, paneKey: string): string | null {
|
||||
const tabId = parsePaneKey(paneKey)?.tabId
|
||||
if (!tabId) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Validate blob contents before commit; install only advertised kinds after durable commit.
|
||||
import { servesWslMovedPaneHistory } from '../../orcad/orcad-migration-wsl-destination'
|
||||
import { readFileSync, rmSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { OrcadMigrationBlobKind } from '../../../shared/orcad-migration/orcad-migration-blob-transfer'
|
||||
@@ -21,6 +22,10 @@ import {
|
||||
assertOrcadMigrationShellHistoryValid,
|
||||
installOrcadMigrationShellHistory
|
||||
} from './orcad-shell-history-installer'
|
||||
import {
|
||||
assertOrcadMigrationTerminalHistoryValid,
|
||||
installOrcadMigrationTerminalHistory
|
||||
} from './orcad-terminal-history-installer'
|
||||
import type {
|
||||
OrcadTerminalRestoreHintPlacement,
|
||||
OrcadTerminalRestoreHintStore
|
||||
@@ -43,6 +48,9 @@ const BLOB_CONTENT_CHECKS: Partial<Record<OrcadMigrationBlobKind, OrcadMigration
|
||||
},
|
||||
'shell-history': ({ key, path, manifest }) => {
|
||||
assertOrcadMigrationShellHistoryValid(key, path, manifest.payload)
|
||||
},
|
||||
'terminal-history': ({ key, path, manifest }) => {
|
||||
assertOrcadMigrationTerminalHistoryValid(key, path, manifest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +79,16 @@ export type OrcadMigrationBlobInstall = {
|
||||
terminalRestoreHints: OrcadTerminalRestoreHintStore
|
||||
/** This host's getHistoryRoot(). */
|
||||
shellHistoryRoot: string
|
||||
/** This host's `getDaemonHistoryDir()`, where its terminals' checkpoints live. */
|
||||
terminalHistoryRoot: string
|
||||
/** The shell binary a new pane on this host would actually run. */
|
||||
paneShellPath: () => string
|
||||
/** Defer without runtime evidence: shells surviving a restart could overwrite installed history. */
|
||||
worktreeHasLiveShells?: (worktreeId: string) => boolean
|
||||
deferredShellHistoryWorktreeIds: Set<string>
|
||||
paneWorktreeId: (paneKey: string) => string | null
|
||||
/** The workspace's folder on this host, or null when it is not here. */
|
||||
workspacePath: (worktreeId: string) => string | null
|
||||
now: number
|
||||
}
|
||||
|
||||
@@ -83,7 +96,11 @@ export type OrcadMigrationBlobInstall = {
|
||||
export function installOrcadMigrationReceivedBlobs(install: OrcadMigrationBlobInstall): void {
|
||||
let firstError: unknown = null
|
||||
// Keep failures independent so other blob kinds can still install.
|
||||
for (const installKind of [installTerminalRestoreHints, installShellHistory]) {
|
||||
for (const installKind of [
|
||||
installTerminalRestoreHints,
|
||||
installShellHistory,
|
||||
installTerminalHistory
|
||||
]) {
|
||||
try {
|
||||
installKind(install)
|
||||
} catch (error) {
|
||||
@@ -95,6 +112,21 @@ export function installOrcadMigrationReceivedBlobs(install: OrcadMigrationBlobIn
|
||||
}
|
||||
}
|
||||
|
||||
function installTerminalHistory(install: OrcadMigrationBlobInstall): void {
|
||||
// Only a WSL host advertises terminal history, so no other destination should hold any.
|
||||
if (!servesWslMovedPaneHistory()) {
|
||||
return
|
||||
}
|
||||
installOrcadMigrationTerminalHistory({
|
||||
migration: install.migration,
|
||||
root: install.root,
|
||||
historyRoot: install.terminalHistoryRoot,
|
||||
paneWorktreeId: install.paneWorktreeId,
|
||||
workspacePath: install.workspacePath,
|
||||
now: install.now
|
||||
})
|
||||
}
|
||||
|
||||
function installShellHistory(install: OrcadMigrationBlobInstall): void {
|
||||
if (!install.worktreeHasLiveShells) {
|
||||
return
|
||||
|
||||
@@ -49,7 +49,8 @@ const receivedDirectoryPath: ReceivedBlobPathResolver = (root, manifest, descrip
|
||||
const RECEIVED_BLOB_DESTINATIONS: Record<OrcadMigrationBlobKind, ReceivedBlobPathResolver> = {
|
||||
'terminal-restore-hints': receivedDirectoryPath,
|
||||
'shell-history': receivedDirectoryPath,
|
||||
'agent-session-records': receivedDirectoryPath
|
||||
'agent-session-records': receivedDirectoryPath,
|
||||
'terminal-history': receivedDirectoryPath
|
||||
}
|
||||
|
||||
export function getOrcadMigrationReceivedBlobPath(
|
||||
|
||||
@@ -12,7 +12,8 @@ import type {
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context'
|
||||
import {
|
||||
createOrcadMigrationSourceScope,
|
||||
createOrcadMigrationExportScope,
|
||||
orcadMigrationScopeOwnsWholeHost,
|
||||
orcadMigrationOwnerMatchesScope,
|
||||
orcadMigrationOwnsRepoId,
|
||||
type OrcadMigrationSourceScope,
|
||||
@@ -25,6 +26,8 @@ export type OrcadMigrationSourceAutomationInspection = {
|
||||
automationRuns: AutomationRun[]
|
||||
blockedAutomationCount: number
|
||||
blockedRunCount: number
|
||||
/** Moved disabled, as the wire requires; the move re-enables these on the host after commit. */
|
||||
enabledAtSourceIds: string[]
|
||||
/** Automations held back only by a run still in flight; they move once it finishes. */
|
||||
runningAutomationNames: string[]
|
||||
}
|
||||
@@ -34,7 +37,7 @@ export function collectOrcadMigrationSourceAutomationState(
|
||||
source: OrcadMigrationManifestSource,
|
||||
catalog: OrcadMigrationCatalogPayload
|
||||
): OrcadMigrationSourceAutomationInspection {
|
||||
const scope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos })
|
||||
const scope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos })
|
||||
const touchedAutomations = state.automations.filter((entry) =>
|
||||
automationTouchesScope(entry, scope)
|
||||
)
|
||||
@@ -48,6 +51,7 @@ export function collectOrcadMigrationSourceAutomationState(
|
||||
const automationRuns: AutomationRun[] = []
|
||||
let blockedAutomationCount = 0
|
||||
let blockedRunCount = 0
|
||||
const enabledAtSourceIds: string[] = []
|
||||
const runningAutomationNames: string[] = []
|
||||
|
||||
for (const [automationId, matching] of automationsById) {
|
||||
@@ -64,7 +68,12 @@ export function collectOrcadMigrationSourceAutomationState(
|
||||
blockedRunCount += runs.length
|
||||
continue
|
||||
}
|
||||
automations.push(projectAutomationToDestination(matching[0]))
|
||||
const projected = projectAutomationToDestination(matching[0])
|
||||
// The projection always sends it disabled; a WSL move re-enables what ran here.
|
||||
if (scope.kind === 'local-wsl' && matching[0].enabled) {
|
||||
enabledAtSourceIds.push(projected.id)
|
||||
}
|
||||
automations.push(projected)
|
||||
automationRuns.push(...runs.map(projectAutomationRunToDestination))
|
||||
}
|
||||
|
||||
@@ -75,11 +84,13 @@ export function collectOrcadMigrationSourceAutomationState(
|
||||
}
|
||||
automations.sort((left, right) => compareKeys(left.id, right.id))
|
||||
automationRuns.sort((left, right) => compareKeys(left.id, right.id))
|
||||
enabledAtSourceIds.sort(compareKeys)
|
||||
return {
|
||||
automations,
|
||||
automationRuns,
|
||||
blockedAutomationCount,
|
||||
blockedRunCount,
|
||||
enabledAtSourceIds,
|
||||
runningAutomationNames: runningAutomationNames.sort(compareKeys)
|
||||
}
|
||||
}
|
||||
@@ -110,6 +121,17 @@ function runTouchesScope(run: AutomationRun, scope: OrcadMigrationSourceScope):
|
||||
}
|
||||
|
||||
function automationCanTransfer(automation: Automation, scope: OrcadMigrationSourceScope): boolean {
|
||||
if (scope.kind === 'local-wsl') {
|
||||
// This desktop schedules it today; the host's scheduler takes over, re-enabled after commit.
|
||||
return (
|
||||
automation.executionTargetType === 'local' &&
|
||||
automation.schedulerOwner === 'local_host_service' &&
|
||||
scope.repoIds.has(getAutomationRunRepoId(automation)) &&
|
||||
ownerCanTransfer(automation.workspaceId, scope) &&
|
||||
contextCanTransfer(automation.runContext, scope) &&
|
||||
contextCanTransfer(automation.sourceContext, scope)
|
||||
)
|
||||
}
|
||||
return (
|
||||
automation.executionTargetType === 'ssh' &&
|
||||
automation.executionTargetId === scope.targetId &&
|
||||
@@ -139,6 +161,12 @@ function contextTouchesScope(
|
||||
context: WorkspaceRunContext | TaskSourceContext | null | undefined,
|
||||
scope: OrcadMigrationSourceScope
|
||||
): boolean {
|
||||
if (!orcadMigrationScopeOwnsWholeHost(scope)) {
|
||||
return (
|
||||
(!context?.hostId || context.hostId === scope.hostId) &&
|
||||
orcadMigrationOwnsRepoId(scope, context?.repoId)
|
||||
)
|
||||
}
|
||||
return (
|
||||
context?.hostId === scope.hostId ||
|
||||
(!context?.hostId && orcadMigrationOwnsRepoId(scope, context?.repoId))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import { withoutOrcadRetainedMarker } from '../../../shared/orcad-retained-marker'
|
||||
import type { ProjectGroup } from '../../../shared/project-group-types'
|
||||
import type { SshTarget } from '../../../shared/ssh-types'
|
||||
import type { Store } from '../../persistence'
|
||||
@@ -15,14 +16,14 @@ export function collectOrcadMigrationSourceCatalog(
|
||||
const allFolders = store.getFolderWorkspaces()
|
||||
const repositories = allRepos
|
||||
.filter((repo) => repoBelongsToOrcadSource(repo, target.id))
|
||||
.map((repo) => structuredClone(repo))
|
||||
.map(withoutOrcadRetainedMarker)
|
||||
const ownedFolderIds = orcadSourceFolderWorkspaceIds(
|
||||
{ repos: allRepos, projectGroups: allGroups, folderWorkspaces: allFolders },
|
||||
target.id
|
||||
)
|
||||
const folderWorkspaces = allFolders
|
||||
.filter((workspace) => ownedFolderIds.has(workspace.id))
|
||||
.map((workspace) => structuredClone(workspace))
|
||||
.map(withoutOrcadRetainedMarker)
|
||||
const projectGroups = collectProjectGroups(
|
||||
allGroups,
|
||||
new Set([
|
||||
|
||||
@@ -14,7 +14,8 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st
|
||||
import { sessionPartitions } from './orcad-source-workspace-session-fragments'
|
||||
import { collectCloseIntents } from './orcad-source-client-browser-intents'
|
||||
import {
|
||||
createOrcadMigrationSourceScope,
|
||||
createOrcadMigrationExportScope,
|
||||
orcadMigrationScopeOwnsWholeHost,
|
||||
orcadMigrationOwnerMatchesScope,
|
||||
orcadMigrationOwnsRepoId,
|
||||
unqualifyOrcadMigrationOwnerKey
|
||||
@@ -36,7 +37,7 @@ export function collectOrcadMigrationSourceClientState(
|
||||
destinationEnvironmentId: string | undefined,
|
||||
eligibleSession: WorkspaceSessionState | undefined
|
||||
): OrcadMigrationSourceClientStateInspection {
|
||||
const scope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos })
|
||||
const scope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos })
|
||||
const blockedCounts = { 'saved-port-forward': 0 }
|
||||
let blockedCount = 0
|
||||
const mobile = collectMobileSelections(state, scope, eligibleSession)
|
||||
@@ -80,7 +81,7 @@ export function collectOrcadMigrationSourceClientState(
|
||||
|
||||
function collectMobileSelections(
|
||||
state: PersistedState,
|
||||
scope: ReturnType<typeof createOrcadMigrationSourceScope>,
|
||||
scope: ReturnType<typeof createOrcadMigrationExportScope>,
|
||||
session: WorkspaceSessionState | undefined
|
||||
):
|
||||
| NonNullable<OrcadMigrationClientStatePayload['mobileClientTabSelectionsByDeviceId']>
|
||||
@@ -115,7 +116,7 @@ function collectMobileSelections(
|
||||
|
||||
function collectUiRouting(
|
||||
state: PersistedState,
|
||||
scope: ReturnType<typeof createOrcadMigrationSourceScope>,
|
||||
scope: ReturnType<typeof createOrcadMigrationExportScope>,
|
||||
session: WorkspaceSessionState | undefined
|
||||
): OrcadMigrationUiRoutingState | undefined {
|
||||
const ui = state.ui
|
||||
@@ -165,12 +166,15 @@ function collectUiRouting(
|
||||
/** Include source-owned tabs from every partition. */
|
||||
function collectSourceOwnedTabIds(
|
||||
state: PersistedState,
|
||||
scope: ReturnType<typeof createOrcadMigrationSourceScope>
|
||||
scope: ReturnType<typeof createOrcadMigrationExportScope>
|
||||
): Set<string> {
|
||||
const tabIds = new Set<string>()
|
||||
for (const [hostId, session] of sessionPartitions(state, 'local')) {
|
||||
for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
|
||||
if (hostId === scope.hostId || orcadMigrationOwnerMatchesScope(ownerKey, scope)) {
|
||||
if (
|
||||
(orcadMigrationScopeOwnsWholeHost(scope) && hostId === scope.hostId) ||
|
||||
orcadMigrationOwnerMatchesScope(ownerKey, scope)
|
||||
) {
|
||||
tabs.forEach((tab) => tabIds.add(tab.id))
|
||||
}
|
||||
}
|
||||
@@ -182,7 +186,7 @@ type EligibleSessionIdentity = { tabIds: ReadonlySet<string>; groupIds: Readonly
|
||||
|
||||
function collectEligibleSessionIdentity(
|
||||
session: WorkspaceSessionState,
|
||||
scope: ReturnType<typeof createOrcadMigrationSourceScope>
|
||||
scope: ReturnType<typeof createOrcadMigrationExportScope>
|
||||
): EligibleSessionIdentity {
|
||||
const tabIds = new Set<string>()
|
||||
const groupIds = new Set<string>()
|
||||
|
||||
@@ -14,7 +14,7 @@ import { collectOrcadMigrationRetiredWorktreeNamespaces } from './orcad-source-r
|
||||
import { collectOrcadMigrationSourceAutomationState } from './orcad-source-automation-state'
|
||||
import { collectOrcadMigrationSourceWorkspaceSession } from './orcad-source-workspace-session'
|
||||
import {
|
||||
createOrcadMigrationSourceScope,
|
||||
createOrcadMigrationExportScope,
|
||||
orcadMigrationOwnerMatchesScope,
|
||||
unqualifyOrcadMigrationOwnerKey
|
||||
} from './orcad-source-scope'
|
||||
@@ -47,12 +47,21 @@ export type OrcadMigrationSourceDormantInspection = {
|
||||
runningAutomationItems: string[]
|
||||
/** Retained on this desktop after confirmed "Move anyway". */
|
||||
leftBehindItems: string[]
|
||||
/** Automations a local WSL move carries disabled, to re-enable on the host once it commits. */
|
||||
automationsEnabledAtSource: string[]
|
||||
}
|
||||
|
||||
/** Confirmed "Move anyway" permits retaining unsupported state; unsaved-draft blockers still apply. */
|
||||
export function orcadMigrationLeavesBehind(state: PersistedState, targetId: string): boolean {
|
||||
return state.sshTargets.some(
|
||||
(target) => target.id === targetId && target.managedServerLeaveBehind !== undefined
|
||||
export function orcadMigrationLeavesBehind(
|
||||
state: PersistedState,
|
||||
targetId: string | null
|
||||
): boolean {
|
||||
// A local WSL move has no "Move anyway": what can't move blocks it.
|
||||
return (
|
||||
targetId !== null &&
|
||||
state.sshTargets.some(
|
||||
(target) => target.id === targetId && target.managedServerLeaveBehind !== undefined
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,9 +70,11 @@ export function collectOrcadMigrationSourceDormantState(
|
||||
source: OrcadMigrationManifestSource,
|
||||
catalog: OrcadMigrationCatalogPayload,
|
||||
storage?: TerminalScrollbackSnapshotStorage,
|
||||
destinationEnvironmentId?: string
|
||||
destinationEnvironmentId?: string,
|
||||
/** A WSL move's slept panes; see `projectOrcadMigrationSessionScrollback`. */
|
||||
daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean
|
||||
): OrcadMigrationSourceDormantInspection {
|
||||
const scope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos })
|
||||
const scope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos })
|
||||
const blockedCounts = emptyBlockedCounts()
|
||||
const destinationRepos = catalog.repositories.map(toOrcadDestinationRepository)
|
||||
const setupByRepoId = new Map(
|
||||
@@ -173,7 +184,8 @@ export function collectOrcadMigrationSourceDormantState(
|
||||
source,
|
||||
catalog,
|
||||
storage,
|
||||
leaveBehind
|
||||
leaveBehind,
|
||||
daemonHistoryLeaf
|
||||
)
|
||||
const blockedSessionItems = [...workspaceSession.blockedItems]
|
||||
const automationState = collectOrcadMigrationSourceAutomationState(state, source, catalog)
|
||||
@@ -242,7 +254,8 @@ export function collectOrcadMigrationSourceDormantState(
|
||||
: automationState.runningAutomationNames.map(
|
||||
(name) => `${name} is running; wait for it to finish, then connect again`
|
||||
),
|
||||
leftBehindItems
|
||||
leftBehindItems,
|
||||
automationsEnabledAtSource: automationState.enabledAtSourceIds
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
collectOrcadMigrationUntransferredDependencyCensus,
|
||||
type OrcadMigrationSourceDependencyCensus
|
||||
} from './orcad-source-dependency-census'
|
||||
import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state'
|
||||
import {
|
||||
collectOrcadMigrationSourceDormantState,
|
||||
type OrcadMigrationSourceDormantInspection
|
||||
} from './orcad-source-dormant-state'
|
||||
import { readOrcadMigrationSourceScrollbackChunk } from './orcad-source-scrollback-state'
|
||||
import {
|
||||
createOrcadMigrationDeltaView,
|
||||
@@ -57,6 +60,24 @@ export class OrcadSourceExportPersistence {
|
||||
).payload
|
||||
}
|
||||
|
||||
/** The export plus what it could not carry, for a move that must refuse rather than drop rows. */
|
||||
inspectOrcadMigrationSourceDormantState(
|
||||
source: OrcadMigrationManifestSource,
|
||||
catalog: OrcadMigrationCatalogPayload,
|
||||
destinationEnvironmentId?: string,
|
||||
daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean
|
||||
): OrcadMigrationSourceDormantInspection {
|
||||
const runtime = this[orcadSourceExportContext]
|
||||
return collectOrcadMigrationSourceDormantState(
|
||||
runtime.state,
|
||||
source,
|
||||
catalog,
|
||||
runtime.terminalScrollbackSnapshotStorage,
|
||||
destinationEnvironmentId,
|
||||
daemonHistoryLeaf
|
||||
)
|
||||
}
|
||||
|
||||
collectOrcadMigrationSourceDrafts(manifest: OrcadMigrationManifest): OrcadMigrationSourceDrafts {
|
||||
return collectOrcadMigrationSourceDrafts(this[orcadSourceExportContext].state, manifest)
|
||||
}
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ import type { OrcadMigrationSourceScope } from './orcad-source-scope'
|
||||
|
||||
const WORKTREE = 'repo-0::/home/dev/src/project-0'
|
||||
const SCOPE: OrcadMigrationSourceScope = {
|
||||
kind: 'ssh-relay',
|
||||
targetId: 'ssh-box',
|
||||
targetGeneration: 1,
|
||||
hostId: toSshExecutionHostId('ssh-box'),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { requireOrcadMigrationSshSource } from '../../../shared/orcad-migration/orcad-migration-source-kind'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../shared/execution-host'
|
||||
import {
|
||||
isOrcadMigrationLocalWslSource,
|
||||
requireOrcadMigrationSshSource
|
||||
} from '../../../shared/orcad-migration/orcad-migration-source-kind'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toSshExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import type {
|
||||
OrcadMigrationCatalogPayload,
|
||||
OrcadMigrationManifestSource
|
||||
@@ -14,10 +21,7 @@ import type { Repo } from '../../../shared/repo-types'
|
||||
import { ownerKeyBelongsToRepo } from '../../orca-profiles/profile-project-worktree-identity'
|
||||
import { repoBelongsToOrcadSource } from './orcad-source-ownership'
|
||||
|
||||
export type OrcadMigrationSourceScope = {
|
||||
targetId: string
|
||||
targetGeneration: number | null
|
||||
hostId: ReturnType<typeof toSshExecutionHostId>
|
||||
type OrcadMigrationScopeRows = {
|
||||
repoIds: ReadonlySet<string>
|
||||
folderWorkspaceKeys: ReadonlySet<string>
|
||||
/** Shared bare repo IDs cannot prove host ownership, so never move or subtract them. */
|
||||
@@ -26,22 +30,57 @@ export type OrcadMigrationSourceScope = {
|
||||
partitionHostId?: string
|
||||
}
|
||||
|
||||
export function orcadMigrationPartitionScope(
|
||||
scope: OrcadMigrationSourceScope,
|
||||
/** A relay-era SSH target: its rows name the target, and live on the target's host id. */
|
||||
export type OrcadMigrationSshSourceScope = OrcadMigrationScopeRows & {
|
||||
kind: 'ssh-relay'
|
||||
targetId: string
|
||||
targetGeneration: number | null
|
||||
hostId: ReturnType<typeof toSshExecutionHostId>
|
||||
}
|
||||
|
||||
/** Local projects inside one WSL distro: rows live on host `local`, and no SSH target is involved. */
|
||||
export type OrcadMigrationLocalWslSourceScope = OrcadMigrationScopeRows & {
|
||||
kind: 'local-wsl'
|
||||
targetId: null
|
||||
targetGeneration: null
|
||||
hostId: typeof LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
export type OrcadMigrationSourceScope =
|
||||
| OrcadMigrationSshSourceScope
|
||||
| OrcadMigrationLocalWslSourceScope
|
||||
|
||||
/**
|
||||
* An SSH conversion moves every row its host holds, so an unmatched host row is an error. A WSL move
|
||||
* takes some of host `local`'s projects; the rest are another project's, never this move's.
|
||||
*/
|
||||
export function orcadMigrationScopeOwnsWholeHost(scope: OrcadMigrationSourceScope): boolean {
|
||||
return scope.kind === 'ssh-relay'
|
||||
}
|
||||
|
||||
/** The scope as seen from one session partition. */
|
||||
export function orcadMigrationPartitionScope<T extends OrcadMigrationSourceScope>(
|
||||
scope: T,
|
||||
partitionHostId: string
|
||||
): OrcadMigrationSourceScope {
|
||||
): T {
|
||||
return { ...scope, partitionHostId }
|
||||
}
|
||||
|
||||
export function createOrcadMigrationSourceScope(args: {
|
||||
type OrcadMigrationScopeArgs = {
|
||||
source: OrcadMigrationManifestSource
|
||||
catalog: OrcadMigrationCatalogPayload
|
||||
/** Detect repo IDs shared by other hosts. */
|
||||
repos: readonly Repo[]
|
||||
}): OrcadMigrationSourceScope {
|
||||
}
|
||||
|
||||
/** For the SSH conversion's own steps (census, subtraction): a WSL manifest reaching them throws. */
|
||||
export function createOrcadMigrationSourceScope(
|
||||
args: OrcadMigrationScopeArgs
|
||||
): OrcadMigrationSshSourceScope {
|
||||
const repoIds = new Set(args.catalog.repositories.map((repo) => repo.id))
|
||||
const source = requireOrcadMigrationSshSource(args.source)
|
||||
return {
|
||||
kind: 'ssh-relay',
|
||||
targetId: source.sshTargetId,
|
||||
targetGeneration: source.sshTargetGeneration,
|
||||
hostId: toSshExecutionHostId(source.sshTargetId),
|
||||
@@ -59,6 +98,34 @@ export function createOrcadMigrationSourceScope(args: {
|
||||
}
|
||||
}
|
||||
|
||||
/** The scope the dormant-state export reads, for either source kind. */
|
||||
export function createOrcadMigrationExportScope(
|
||||
args: OrcadMigrationScopeArgs
|
||||
): OrcadMigrationSourceScope {
|
||||
if (!isOrcadMigrationLocalWslSource(args.source)) {
|
||||
return createOrcadMigrationSourceScope(args)
|
||||
}
|
||||
const repoIds = new Set(args.catalog.repositories.map((repo) => repo.id))
|
||||
return {
|
||||
kind: 'local-wsl',
|
||||
targetId: null,
|
||||
targetGeneration: null,
|
||||
hostId: LOCAL_EXECUTION_HOST_ID,
|
||||
repoIds,
|
||||
folderWorkspaceKeys: new Set(
|
||||
args.catalog.folderWorkspaces.map((workspace) => `folder:${workspace.id}`)
|
||||
),
|
||||
// Another host registering the same repo id makes its unqualified keys ambiguous.
|
||||
sharedRepoIds: new Set(
|
||||
args.repos
|
||||
.filter(
|
||||
(repo) => repoIds.has(repo.id) && getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
.map((repo) => repo.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function orcadMigrationOwnsRepoId(
|
||||
scope: OrcadMigrationSourceScope,
|
||||
repoId: string | null | undefined
|
||||
@@ -100,6 +167,22 @@ export function orcadMigrationOwnerMatchesScope(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An unqualified key naming a moved repo that another host also registers: it may be this move's
|
||||
* row or the other host's, so a WSL move can neither carry nor ignore what it owns.
|
||||
*/
|
||||
export function orcadMigrationOwnerKeyIsAmbiguous(
|
||||
value: string | null | undefined,
|
||||
scope: OrcadMigrationSourceScope
|
||||
): boolean {
|
||||
if (!value || isWorktreeHostIdentity(value)) {
|
||||
return false
|
||||
}
|
||||
return [...scope.sharedRepoIds].some(
|
||||
(repoId) => scope.repoIds.has(repoId) && ownerKeyBelongsToRepo(value, repoId)
|
||||
)
|
||||
}
|
||||
|
||||
export function unqualifyOrcadMigrationOwnerKey(value: string): string {
|
||||
return isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export function deleteUnreferencedOrcadMigrationScrollback(
|
||||
>,
|
||||
refs: Iterable<string>
|
||||
): void {
|
||||
// Host-run panes whose local copy this deletes repaint from the host (session.readTerminalScrollback).
|
||||
const live = new Set([
|
||||
...sessionPartitions(runtime.state, LOCAL_EXECUTION_HOST_ID).flatMap(([, session]) => [
|
||||
...collectTerminalScrollbackSnapshotRefs(session)
|
||||
|
||||
@@ -42,7 +42,12 @@ export function projectOrcadMigrationSessionScrollback(
|
||||
session: WorkspaceSessionState,
|
||||
storage?: TerminalScrollbackSnapshotStorage,
|
||||
/** "Move anyway" can retain untransferable history while moving its pane. */
|
||||
leaveBehind = false
|
||||
leaveBehind = false,
|
||||
/**
|
||||
* A WSL move's panes whose daemon checkpoint travels as a `terminal-history` blob: the host's
|
||||
* daemon restores it, so no renderer snapshot or ref may also paint it.
|
||||
*/
|
||||
daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean
|
||||
): ProjectedOrcadMigrationScrollback {
|
||||
const projected = structuredClone(session)
|
||||
const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = []
|
||||
@@ -59,6 +64,10 @@ export function projectOrcadMigrationSessionScrollback(
|
||||
...Object.keys(sourceLayout.scrollbackRefsByLeafId ?? {})
|
||||
])
|
||||
for (const leafId of [...leafIds].sort(compareKeys)) {
|
||||
// Its checkpoint is newer than any snapshot saved before, and the host restores it itself.
|
||||
if (daemonHistoryLeaf?.(tabId, leafId)) {
|
||||
continue
|
||||
}
|
||||
const buffer = inlineLeafBuffer(localOnly, sourceLayout.buffersByLeafId, leafId)
|
||||
const sourceRef = sourceLayout.scrollbackRefsByLeafId?.[leafId]
|
||||
const ref = buffer ? makeTerminalScrollbackSnapshotRef(tabId, leafId) : sourceRef
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import {
|
||||
} from '../../../shared/workspace-session-stranded-partition-adoption'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import {
|
||||
createOrcadMigrationSourceScope,
|
||||
createOrcadMigrationExportScope,
|
||||
orcadMigrationOwnerMatchesScope,
|
||||
orcadMigrationPartitionScope
|
||||
} from './orcad-source-scope'
|
||||
@@ -31,7 +31,7 @@ export function desktopDrafts(
|
||||
catalog: OrcadMigrationCatalogPayload
|
||||
): string[] {
|
||||
const scope = orcadMigrationPartitionScope(
|
||||
createOrcadMigrationSourceScope({ source, catalog, repos: state.repos }),
|
||||
createOrcadMigrationExportScope({ source, catalog, repos: state.repos }),
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
return Object.entries(state.workspaceSession.openFilesByWorktree ?? {}).flatMap(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { sessionPartitions } from './orcad-source-workspace-session-fragments'
|
||||
import {
|
||||
orcadMigrationOwnerMatchesScope,
|
||||
orcadMigrationPartitionScope,
|
||||
orcadMigrationScopeOwnsWholeHost,
|
||||
type OrcadMigrationSourceScope
|
||||
} from './orcad-source-scope'
|
||||
|
||||
@@ -22,6 +23,10 @@ export function orcadMigrationSessionPartitions(
|
||||
scope: OrcadMigrationSourceScope
|
||||
): [string, WorkspaceSessionState][] {
|
||||
const partitions = sessionPartitions(state, LOCAL_EXECUTION_HOST_ID)
|
||||
// A WSL move's host is `local` itself: no second copy to reconcile with.
|
||||
if (!orcadMigrationScopeOwnsWholeHost(scope)) {
|
||||
return partitions
|
||||
}
|
||||
const host = partitions.find(([hostId]) => hostId === scope.hostId)?.[1]
|
||||
if (!host) {
|
||||
return partitions
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../shared/execution-host'
|
||||
import { collectUnsupportedSessionItems } from './orcad-source-workspace-session-eligibility'
|
||||
import type { OrcadMigrationSourceScope } from './orcad-source-scope'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
|
||||
// `repo-1` is selected but another host registers it too, so its unqualified keys are ambiguous.
|
||||
const ROWS = {
|
||||
repoIds: new Set(['repo-1']),
|
||||
folderWorkspaceKeys: new Set<string>(),
|
||||
sharedRepoIds: new Set(['repo-1'])
|
||||
}
|
||||
const SSH_SCOPE: OrcadMigrationSourceScope = {
|
||||
...ROWS,
|
||||
kind: 'ssh-relay',
|
||||
targetId: 'target-1',
|
||||
targetGeneration: 1,
|
||||
hostId: toSshExecutionHostId('target-1')
|
||||
}
|
||||
const WSL_SCOPE: OrcadMigrationSourceScope = {
|
||||
...ROWS,
|
||||
kind: 'local-wsl',
|
||||
targetId: null,
|
||||
targetGeneration: null,
|
||||
hostId: LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
function otherPartitionWithAmbiguousRecord(): WorkspaceSessionState {
|
||||
const paneKey = 'tab-elsewhere:leaf-1'
|
||||
const record: SleepingAgentSessionRecord = {
|
||||
paneKey,
|
||||
tabId: 'tab-elsewhere',
|
||||
worktreeId: 'repo-1::/w',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: '',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
return { ...getDefaultWorkspaceSession(), sleepingAgentSessionsByPaneKey: { [paneKey]: record } }
|
||||
}
|
||||
|
||||
describe("a session row whose owner key can't be attributed, in another host's partition", () => {
|
||||
it('leaves an SSH conversion as it was: the row is not the conversion’s', () => {
|
||||
expect(
|
||||
collectUnsupportedSessionItems(
|
||||
otherPartitionWithAmbiguousRecord(),
|
||||
SSH_SCOPE,
|
||||
false,
|
||||
new Set(),
|
||||
new Set()
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a WSL move, since the row may be the selected project’s', () => {
|
||||
expect(
|
||||
collectUnsupportedSessionItems(
|
||||
otherPartitionWithAmbiguousRecord(),
|
||||
WSL_SCOPE,
|
||||
false,
|
||||
new Set(),
|
||||
new Set()
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
+15
-3
@@ -6,8 +6,10 @@ import type {
|
||||
WorkspaceSessionState
|
||||
} from '../../../shared/workspace-session-state-types'
|
||||
import {
|
||||
orcadMigrationOwnerKeyIsAmbiguous,
|
||||
orcadMigrationOwnerMatchesScope,
|
||||
orcadMigrationOwnsRepoId,
|
||||
orcadMigrationScopeOwnsWholeHost,
|
||||
unqualifyOrcadMigrationOwnerKey,
|
||||
type OrcadMigrationSourceScope
|
||||
} from './orcad-source-scope'
|
||||
@@ -26,11 +28,19 @@ export function collectUnsupportedSessionItems(
|
||||
sourceTerminalTabIds: ReadonlySet<string>
|
||||
): string[] {
|
||||
const owns = (ownerKey: string): boolean => orcadMigrationOwnerMatchesScope(ownerKey, scope)
|
||||
// A WSL move takes some of host `local`'s projects; the others, and rows a past move retained,
|
||||
// stay on this desktop untouched, so only the selected projects' rows can refuse it.
|
||||
const wholeHost = sourceHostPartition && orcadMigrationScopeOwnsWholeHost(scope)
|
||||
// In a WSL move, an unattributable key that may be the selected project's refuses rather than
|
||||
// staying behind; an SSH conversion keeps its own matching.
|
||||
const matches = (ownerKey: string): boolean =>
|
||||
Boolean(ownerKey) && (sourceHostPartition || owns(ownerKey))
|
||||
Boolean(ownerKey) &&
|
||||
(wholeHost ||
|
||||
owns(ownerKey) ||
|
||||
(scope.kind === 'local-wsl' && orcadMigrationOwnerKeyIsAmbiguous(ownerKey, scope)))
|
||||
const items: string[] = []
|
||||
// Foreign projects stay behind; unsaved drafts still require preservation.
|
||||
if (sourceHostPartition) {
|
||||
if (wholeHost) {
|
||||
for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) {
|
||||
if (!owns(ownerKey)) {
|
||||
items.push(...unsavedDraftItems(files, ownerKey))
|
||||
@@ -39,7 +49,9 @@ export function collectUnsupportedSessionItems(
|
||||
}
|
||||
// The terminal gate proves exit before moves, so PTY bindings and shutdown markers can be dropped.
|
||||
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
|
||||
const touchesSource = matches(record.worktreeId) || record.connectionId === scope.targetId
|
||||
const touchesSource =
|
||||
matches(record.worktreeId) ||
|
||||
(scope.targetId !== null && record.connectionId === scope.targetId)
|
||||
if (
|
||||
touchesSource &&
|
||||
!transferableSleepingAgentSession(record, session, scope, terminalTabIds) &&
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '../../orca-profiles/profile-session-owner-transfer'
|
||||
import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots'
|
||||
import {
|
||||
createOrcadMigrationSourceScope,
|
||||
createOrcadMigrationExportScope,
|
||||
orcadMigrationOwnerMatchesScope,
|
||||
orcadMigrationPartitionScope,
|
||||
unqualifyOrcadMigrationOwnerKey
|
||||
@@ -63,11 +63,19 @@ export function collectOrcadMigrationSourceWorkspaceSession(
|
||||
source: OrcadMigrationManifestSource,
|
||||
catalog: OrcadMigrationCatalogPayload,
|
||||
storage?: TerminalScrollbackSnapshotStorage,
|
||||
leaveBehind = false
|
||||
leaveBehind = false,
|
||||
daemonHistoryLeaf?: (tabId: string, leafId: string) => boolean
|
||||
): OrcadMigrationSourceWorkspaceSessionInspection {
|
||||
let collected: OrcadMigrationSourceWorkspaceSessionInspection
|
||||
try {
|
||||
collected = collectWorkspaceSession(state, source, catalog, storage, leaveBehind)
|
||||
collected = collectWorkspaceSession(
|
||||
state,
|
||||
source,
|
||||
catalog,
|
||||
storage,
|
||||
leaveBehind,
|
||||
daemonHistoryLeaf
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[migration] Unreadable workspace session blocks the move:', error)
|
||||
collected = {
|
||||
@@ -97,9 +105,10 @@ function collectWorkspaceSession(
|
||||
source: OrcadMigrationManifestSource,
|
||||
catalog: OrcadMigrationCatalogPayload,
|
||||
storage: TerminalScrollbackSnapshotStorage | undefined,
|
||||
leaveBehind: boolean
|
||||
leaveBehind: boolean,
|
||||
daemonHistoryLeaf: ((tabId: string, leafId: string) => boolean) | undefined
|
||||
): OrcadMigrationSourceWorkspaceSessionInspection {
|
||||
const sourceScope = createOrcadMigrationSourceScope({ source, catalog, repos: state.repos })
|
||||
const sourceScope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos })
|
||||
const fragments: SessionFragment[] = []
|
||||
const blockedItems: string[] = []
|
||||
const leftBehindItems: string[] = []
|
||||
@@ -175,7 +184,8 @@ function collectWorkspaceSession(
|
||||
const projected = projectOrcadMigrationSessionScrollback(
|
||||
projectSessionToDestination(fragment, scope),
|
||||
storage,
|
||||
leaveBehind
|
||||
leaveBehind,
|
||||
daemonHistoryLeaf
|
||||
)
|
||||
blockedItems.push(...projected.blockedItems)
|
||||
leftBehindItems.push(...projected.leftBehindItems)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree
|
||||
import {
|
||||
createOrcadMigrationSourceScope,
|
||||
orcadMigrationOwnerMatchesScope,
|
||||
orcadMigrationScopeOwnsWholeHost,
|
||||
unqualifyOrcadMigrationOwnerKey,
|
||||
type OrcadMigrationSourceScope
|
||||
} from './orcad-source-scope'
|
||||
@@ -21,23 +22,28 @@ export function inspectOrcadSourceWorktreeMetadata(
|
||||
) {
|
||||
const rows: { sourceKey: string; meta: WorktreeMeta }[] = []
|
||||
let blockedCount = 0
|
||||
const wholeHost = orcadMigrationScopeOwnsWholeHost(scope)
|
||||
for (const [sourceKey, meta] of Object.entries(state.worktreeMeta)) {
|
||||
const host = getExecutionHostIdFromWorktreeHostIdentity(sourceKey)
|
||||
if ((host && host !== scope.hostId) || (meta.hostId && meta.hostId !== scope.hostId)) {
|
||||
if (host === scope.hostId || meta.hostId === scope.hostId) {
|
||||
if (
|
||||
(host === scope.hostId || meta.hostId === scope.hostId) &&
|
||||
(wholeHost || orcadMigrationOwnerMatchesScope(sourceKey, scope))
|
||||
) {
|
||||
blockedCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (orcadMigrationOwnerMatchesScope(sourceKey, scope, meta.hostId)) {
|
||||
rows.push({ sourceKey, meta })
|
||||
} else if (meta.hostId === scope.hostId || host === scope.hostId) {
|
||||
} else if (wholeHost && (meta.hostId === scope.hostId || host === scope.hostId)) {
|
||||
blockedCount++
|
||||
}
|
||||
}
|
||||
const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat())
|
||||
for (const [identity, meta] of Object.entries(state.worktreeMetaByIdentity ?? {})) {
|
||||
if (meta.hostId === scope.hostId && !referenced.has(identity)) {
|
||||
// An unreferenced identity names no worktree, so only a whole-host move can claim it.
|
||||
if (wholeHost && meta.hostId === scope.hostId && !referenced.has(identity)) {
|
||||
blockedCount++
|
||||
}
|
||||
}
|
||||
@@ -45,6 +51,9 @@ export function inspectOrcadSourceWorktreeMetadata(
|
||||
if (getExecutionHostIdFromWorktreeHostIdentity(alias) !== scope.hostId) {
|
||||
continue
|
||||
}
|
||||
if (!wholeHost && !orcadMigrationOwnerMatchesScope(alias, scope)) {
|
||||
continue
|
||||
}
|
||||
if (isDanglingAlias(state, identities) && orcadMigrationOwnerMatchesScope(alias, scope)) {
|
||||
continue // Dangling aliases carry no metadata; any legacy row migrates separately.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Installs a moved pane's `terminal-history` blob as an interrupted session in this host's daemon
|
||||
* history folder, under an id derived from the pane. The pane's first start claims that session and
|
||||
* cold-restores the checkpoint once (history, screen and cwd); the daemon's own checkpoints take over.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import {
|
||||
MOVED_PANE_HISTORY_SESSION_TAG,
|
||||
parseOrcadMigrationTerminalHistoryText,
|
||||
type OrcadMigrationTerminalHistoryDocument
|
||||
} from '../../../shared/orcad-migration/orcad-migration-terminal-history'
|
||||
import { PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { getHistorySessionDirName } from '../../daemon/history-paths'
|
||||
import { isTerminalCheckpointFile } from '../../daemon/terminal-history-checkpoint-reader'
|
||||
import type { SessionMeta } from '../../daemon/terminal-history-metadata'
|
||||
import { syncDirectoryDurablySync } from '../../durable-file-write'
|
||||
import {
|
||||
getOrcadMigrationReceivedBlobPath,
|
||||
listOrcadMigrationReceivedBlobNames
|
||||
} from './orcad-migration-blob-staging'
|
||||
import type { OrcadMigrationRequestIdentity } from './orcad-migration-staged-file'
|
||||
|
||||
const UTF8 = new TextDecoder('utf-8', { fatal: true })
|
||||
|
||||
export function orcadMigrationTerminalHistoryKey(paneKey: string): string {
|
||||
return createHash('sha256').update(paneKey).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
/** The session a moved pane restores as on this host; minted for its worktree like any other. */
|
||||
export function movedPaneHistorySessionId(worktreeId: string, paneKey: string): string {
|
||||
return `${worktreeId}${PTY_SESSION_ID_SEPARATOR}${MOVED_PANE_HISTORY_SESSION_TAG}${orcadMigrationTerminalHistoryKey(paneKey)}`
|
||||
}
|
||||
|
||||
/** Before the commit: the bytes must be a checkpoint for a terminal pane this manifest moves. */
|
||||
export function assertOrcadMigrationTerminalHistoryValid(
|
||||
key: string,
|
||||
path: string,
|
||||
manifest: OrcadMigrationManifest
|
||||
): void {
|
||||
const document = readDocument(path)
|
||||
if (orcadMigrationTerminalHistoryKey(document.paneKey) !== key) {
|
||||
throw new Error('orcad_migration_terminal_history_key_mismatch')
|
||||
}
|
||||
if (!isTerminalCheckpointFile(document.checkpoint)) {
|
||||
throw new Error('orcad_migration_terminal_history_checkpoint_invalid')
|
||||
}
|
||||
if (findPaneWorktreeId(manifest, document.paneKey) === null) {
|
||||
throw new Error('orcad_migration_terminal_history_pane_not_moved')
|
||||
}
|
||||
}
|
||||
|
||||
export type OrcadMigrationTerminalHistoryInstall = {
|
||||
migration: OrcadMigrationRequestIdentity
|
||||
root: string
|
||||
/** This host's daemon history folder (`getDaemonHistoryDir()`). */
|
||||
historyRoot: string
|
||||
/** The worktree that owns the pane on this host, or null when the pane is not here. */
|
||||
paneWorktreeId: (paneKey: string) => string | null
|
||||
/** Where a pane with no recorded folder starts: its workspace's folder on this host. */
|
||||
workspacePath: (worktreeId: string) => string | null
|
||||
now: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: a session folder that already holds `meta.json` is the pane's own by now (restored,
|
||||
* or written since), so the blob is only deleted. A write failure keeps the blob for the retry.
|
||||
*/
|
||||
export function installOrcadMigrationTerminalHistory(
|
||||
install: OrcadMigrationTerminalHistoryInstall
|
||||
): void {
|
||||
for (const key of listOrcadMigrationReceivedBlobNames(
|
||||
install.root,
|
||||
install.migration,
|
||||
'terminal-history'
|
||||
)) {
|
||||
const path = getOrcadMigrationReceivedBlobPath(install.root, install.migration, {
|
||||
blobKind: 'terminal-history',
|
||||
key
|
||||
})
|
||||
let document: OrcadMigrationTerminalHistoryDocument | null = null
|
||||
try {
|
||||
document = readDocument(path)
|
||||
} catch (error) {
|
||||
// The pre-commit check passed these bytes, so only a damaged file lands here; the pane
|
||||
// starts fresh rather than holding back the rest of the move.
|
||||
console.error(
|
||||
`[orcad-migration] dropped unreadable terminal history for ${install.migration.migrationId}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
const worktreeId = document ? install.paneWorktreeId(document.paneKey) : null
|
||||
const cwd = worktreeId ? (document?.cwd ?? install.workspacePath(worktreeId)) : null
|
||||
if (document && worktreeId && cwd) {
|
||||
writeSessionFolder(
|
||||
install.historyRoot,
|
||||
movedPaneHistorySessionId(worktreeId, document.paneKey),
|
||||
{ ...document, cwd },
|
||||
install.now
|
||||
)
|
||||
}
|
||||
rmSync(path, { force: true })
|
||||
syncDirectoryDurablySync(dirname(path))
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionFolder(
|
||||
historyRoot: string,
|
||||
sessionId: string,
|
||||
document: OrcadMigrationTerminalHistoryDocument & { cwd: string },
|
||||
now: number
|
||||
): void {
|
||||
const dir = join(historyRoot, getHistorySessionDirName(sessionId))
|
||||
const metaPath = join(dir, 'meta.json')
|
||||
if (existsSync(metaPath)) {
|
||||
return
|
||||
}
|
||||
// A crash before meta.json left no session yet; redo the folder whole.
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
writeAtomically(
|
||||
join(dir, 'checkpoint.json'),
|
||||
JSON.stringify({ ...document.checkpoint, cwd: document.cwd })
|
||||
)
|
||||
const meta: SessionMeta = {
|
||||
cwd: document.cwd,
|
||||
cols: document.cols,
|
||||
rows: document.rows,
|
||||
startedAt: new Date(now).toISOString(),
|
||||
// Why null: only an unended session cold-restores, and this one never ran on this host.
|
||||
endedAt: null,
|
||||
exitCode: null
|
||||
}
|
||||
// Why last: meta.json is what makes the folder a session.
|
||||
writeAtomically(metaPath, JSON.stringify(meta))
|
||||
syncDirectoryDurablySync(dir)
|
||||
}
|
||||
|
||||
function writeAtomically(path: string, text: string): void {
|
||||
writeFileSync(`${path}.tmp`, text, { mode: 0o600 })
|
||||
renameSync(`${path}.tmp`, path)
|
||||
}
|
||||
|
||||
function readDocument(path: string): OrcadMigrationTerminalHistoryDocument {
|
||||
let text: string
|
||||
try {
|
||||
text = UTF8.decode(readFileSync(path))
|
||||
} catch {
|
||||
throw new Error('orcad_migration_terminal_history_not_utf8')
|
||||
}
|
||||
return parseOrcadMigrationTerminalHistoryText(text)
|
||||
}
|
||||
|
||||
function findPaneWorktreeId(manifest: OrcadMigrationManifest, paneKey: string): string | null {
|
||||
const pane = parsePaneKey(paneKey)
|
||||
const session = manifest.payload.dormantState?.workspaceSession
|
||||
if (!pane || !session) {
|
||||
return null
|
||||
}
|
||||
const owner = Object.entries(session.tabsByWorktree ?? {}).find(([, tabs]) =>
|
||||
tabs.some((tab) => tab.id === pane.tabId)
|
||||
)?.[0]
|
||||
const layout = session.terminalLayoutsByTabId?.[pane.tabId]
|
||||
return owner && layout ? owner : null
|
||||
}
|
||||
+14
-10
@@ -6,7 +6,6 @@ import { join } from 'node:path'
|
||||
import type { OrcadMigrationBlobDescriptor } from '../../../shared/orcad-migration/orcad-migration-blob-transfer'
|
||||
import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import { getOrcadMigrationReceivedBlobPath } from './orcad-migration-blob-staging'
|
||||
import { buildWslMoveRestoreHintsBlob } from '../moving-wsl-projects/wsl-move-restore-hints-blob'
|
||||
import {
|
||||
commitStagedOrcadMigrationCatalogDurably,
|
||||
getOrcadMigrationCatalogState
|
||||
@@ -82,15 +81,20 @@ function encodedHints(): {
|
||||
descriptor: OrcadMigrationBlobDescriptor
|
||||
bytes: Buffer
|
||||
} {
|
||||
const blob = buildWslMoveRestoreHintsBlob([
|
||||
{ paneKey: PANE, cwd: '/home/me/src' },
|
||||
{ paneKey: OTHER_PANE, cwd: '/srv/app' },
|
||||
{ paneKey: ABSENT_PANE, cwd: '/srv/gone' }
|
||||
])
|
||||
if (!blob) {
|
||||
throw new Error('expected a hints blob')
|
||||
}
|
||||
return blob
|
||||
// What an older desktop sends; this build installs it but no longer sends one.
|
||||
return rawBlob(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
hints: [
|
||||
{ paneKey: PANE, cwd: '/home/me/src' },
|
||||
{ paneKey: OTHER_PANE, cwd: '/srv/app' },
|
||||
{ paneKey: ABSENT_PANE, cwd: '/srv/gone' }
|
||||
]
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function rawBlob(bytes: Buffer): {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toPersistedAutomationHostFilter } from '../../../shared/automation-host-filter'
|
||||
import { wslMoveAutomationHostFilterUpdate } from './wsl-move-automation-filter-rewrite'
|
||||
|
||||
const desktop = toPersistedAutomationHostFilter({
|
||||
kind: 'host',
|
||||
host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }
|
||||
})
|
||||
const otherHost = { kind: 'host' as const, hostKey: 'host:ssh:staging' }
|
||||
|
||||
describe('wslMoveAutomationHostFilterUpdate', () => {
|
||||
it('widens a filter on this desktop to all hosts when the move carried automations', () => {
|
||||
expect(wslMoveAutomationHostFilterUpdate(desktop, 2)).toEqual({ kind: 'all' })
|
||||
})
|
||||
|
||||
it('keeps the filter when nothing moved, or it shows all hosts or another host', () => {
|
||||
expect(wslMoveAutomationHostFilterUpdate(desktop, 0)).toBeNull()
|
||||
expect(wslMoveAutomationHostFilterUpdate({ kind: 'all' }, 2)).toBeNull()
|
||||
expect(wslMoveAutomationHostFilterUpdate(otherHost, 2)).toBeNull()
|
||||
expect(wslMoveAutomationHostFilterUpdate(undefined, 2)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The Automations page host filter after a WSL move. It selects one host or all of them, so it
|
||||
* cannot also cover the WSL host; a filter on this desktop would hide every automation the move
|
||||
* carried. Only then does it widen to All hosts, once: the ledgered run never repeats, so a
|
||||
* later choice of this desktop stays.
|
||||
*/
|
||||
import type { PersistedAutomationHostFilter } from '../../../shared/automation-host-filter'
|
||||
import { hostStableKey } from '../../../shared/automation-owner-key'
|
||||
|
||||
const DESKTOP_SELF_HOST_KEY = hostStableKey({
|
||||
authority: { kind: 'desktop' },
|
||||
selector: { kind: 'self' }
|
||||
})
|
||||
|
||||
/** The filter to persist, or null when it stays. */
|
||||
export function wslMoveAutomationHostFilterUpdate(
|
||||
filter: PersistedAutomationHostFilter | undefined,
|
||||
movedAutomationCount: number
|
||||
): PersistedAutomationHostFilter | null {
|
||||
return movedAutomationCount > 0 &&
|
||||
filter?.kind === 'host' &&
|
||||
filter.hostKey === DESKTOP_SELF_HOST_KEY
|
||||
? { kind: 'all' }
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
KnownRuntimeEnvironment,
|
||||
OrcadDeploymentLink
|
||||
} from '../../../shared/runtime-environments'
|
||||
import {
|
||||
emptyWslMoveBrowserCookieCopyCounts,
|
||||
WSL_MOVE_BROWSER_COOKIES_STORE_ID
|
||||
} from '../../../shared/wsl-move/wsl-move-browser-cookie-copy'
|
||||
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate'
|
||||
import { parseWslMoveCutover } 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 { deriveBrowserRoutePartitionStorageScope } from '../../browser/browser-route-identity'
|
||||
import { wslHostBrowserRouteStorage } from '../../browser/wsl-host-browser-route-partition'
|
||||
import { listWslMoveCutovers } from './wsl-move-cutover-journal'
|
||||
import type { WslMoveBrowserCookieCopyDependencies } from './wsl-move-browser-cookie-copy'
|
||||
import { previewWslMoveBrowserCookieCopy } from './wsl-move-browser-cookie-copy'
|
||||
import { registerWslMoveBrowserCookieStore } from './wsl-move-browser-cookie-store'
|
||||
import {
|
||||
runGatedWslMoveDesktopRewritePass,
|
||||
runWslMoveDesktopRewritePass
|
||||
} from './wsl-move-desktop-rewrite'
|
||||
import {
|
||||
resetWslMoveDesktopRewriteStoresForTests,
|
||||
setWslMoveDesktopRewriteRegistryCompleteForTests
|
||||
} from './wsl-move-desktop-rewrite-stores'
|
||||
|
||||
vi.mock('../../browser/browser-partition-cookie-copy', () => ({
|
||||
copyBrowserPartitionCookies: vi.fn(),
|
||||
flushBrowserPartitionCookies: vi.fn()
|
||||
}))
|
||||
vi.mock('../../browser/browser-session-registry', () => ({ browserSessionRegistry: {} }))
|
||||
vi.mock('../../browser/browser-session-startup', () => ({ browserSessionsInitialized: vi.fn() }))
|
||||
vi.mock('../../browser/browser-route-partition-binding-runtime', () => ({
|
||||
activeBrowserRoutePartitionOrcaProfileId: vi.fn(),
|
||||
currentBrowserRoutePartitionBindingStore: vi.fn()
|
||||
}))
|
||||
vi.mock('../../browser/browser-route-partition-storage-dependencies', () => ({
|
||||
releaseEvictedBrowserRoutePartitionStorage: vi.fn()
|
||||
}))
|
||||
|
||||
const LOCAL_DEFAULT = 'persist:orca-browser-default'
|
||||
const LOCAL_WORK = 'persist:orca-browser-work'
|
||||
const SSH_PARTITION = `persist:orca-browser-v1-${'5'.repeat(64)}`
|
||||
|
||||
type Jar = Map<string, string>
|
||||
|
||||
/** One desktop: its partitions' cookie jars, its environments, and what touched them. */
|
||||
type Desktop = {
|
||||
jars: Map<string, Jar>
|
||||
environments: KnownRuntimeEnvironment[]
|
||||
sessionsReady: boolean
|
||||
copies: { source: string; destination: string }[]
|
||||
flushed: string[]
|
||||
failFlush: boolean
|
||||
}
|
||||
|
||||
let userDataPath: string
|
||||
let desktop: Desktop
|
||||
|
||||
function jarOf(partition: string): Jar {
|
||||
let jar = desktop.jars.get(partition)
|
||||
if (!jar) {
|
||||
jar = new Map()
|
||||
desktop.jars.set(partition, jar)
|
||||
}
|
||||
return jar
|
||||
}
|
||||
|
||||
function hostPartition(storageScope: string, browserProfileId: string): string {
|
||||
const digest = createHash('sha256').update(`${storageScope}|${browserProfileId}`).digest('hex')
|
||||
return `persist:orca-browser-v1-${digest}`
|
||||
}
|
||||
|
||||
/** The moved-to WSL host's partition for one browser profile. */
|
||||
function hostPartitionOf(browserProfileId: string): string {
|
||||
const scope = deriveBrowserRoutePartitionStorageScope({
|
||||
orcaProfileId: 'orca-profile-a',
|
||||
environmentId: 'wsl:Ubuntu'
|
||||
})
|
||||
return hostPartition(scope, browserProfileId)
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
overrides: Partial<WslMoveBrowserCookieCopyDependencies> = {}
|
||||
): WslMoveBrowserCookieCopyDependencies {
|
||||
return {
|
||||
userDataPath,
|
||||
sessionsReady: () => desktop.sessionsReady,
|
||||
orcaProfileId: () => 'orca-profile-a',
|
||||
listEnvironments: () => desktop.environments,
|
||||
listBrowserProfiles: () => [
|
||||
{ id: 'default', partition: LOCAL_DEFAULT },
|
||||
{ id: 'work', partition: LOCAL_WORK }
|
||||
],
|
||||
resolveHostStorage: wslHostBrowserRouteStorage,
|
||||
bindHostPartition: (storage, browserProfileId) =>
|
||||
hostPartition(storage.storageScope, browserProfileId),
|
||||
copyCookies: async (source, destination) => {
|
||||
desktop.copies.push({ source, destination })
|
||||
const counts = emptyWslMoveBrowserCookieCopyCounts()
|
||||
const target = jarOf(destination)
|
||||
for (const [name, value] of jarOf(source)) {
|
||||
if (target.has(name)) {
|
||||
counts.skippedPresent += 1
|
||||
} else {
|
||||
target.set(name, value)
|
||||
counts.copied += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
},
|
||||
flushCookies: async (partition) => {
|
||||
if (desktop.failFlush) {
|
||||
throw new Error('crashed before the ledger entry')
|
||||
}
|
||||
desktop.flushed.push(partition)
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const DEPLOYMENTS = {
|
||||
wsl: { kind: 'wsl', distro: 'Ubuntu', localPort: 41_001, remotePort: 6768 },
|
||||
'managed-ssh': {
|
||||
sshTargetId: 'target-a',
|
||||
sshTargetGeneration: 1,
|
||||
localPort: 41_001,
|
||||
remotePort: 6768
|
||||
},
|
||||
unlinked: null
|
||||
} satisfies Record<string, OrcadDeploymentLink | null>
|
||||
|
||||
/** A runtime environment as each host kind persists it; `unlinked` lost its deployment link. */
|
||||
function hostEnvironment(kind: keyof typeof DEPLOYMENTS, id: string): KnownRuntimeEnvironment {
|
||||
const deployment = DEPLOYMENTS[kind]
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
pairingRevision: 1,
|
||||
...(deployment ? { orcadDeployment: deployment } : {}),
|
||||
preferredEndpointId: 'endpoint-a',
|
||||
endpoints: [
|
||||
{
|
||||
id: 'endpoint-a',
|
||||
kind: 'websocket',
|
||||
label: id,
|
||||
endpoint: 'ws://127.0.0.1:41001',
|
||||
deviceToken: 'token-a',
|
||||
publicKeyB64: 'host-key-a'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const wslHost = (kind: 'wsl' | 'unlinked' = 'wsl'): KnownRuntimeEnvironment =>
|
||||
hostEnvironment(kind, 'wsl:Ubuntu')
|
||||
const record = (migrationId = 'move-1') =>
|
||||
listWslMoveCutovers(userDataPath).find((entry) => entry.migrationId === migrationId)
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-browser-cookies-'))
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
setWslMoveDesktopRewriteRegistryCompleteForTests(true)
|
||||
desktop = {
|
||||
jars: new Map(),
|
||||
environments: [
|
||||
wslHost(),
|
||||
hostEnvironment('managed-ssh', 'ssh-managed'),
|
||||
// Plain SSH and manually paired servers carry no deployment link.
|
||||
hostEnvironment('unlinked', 'ssh-plain'),
|
||||
hostEnvironment('unlinked', 'paired')
|
||||
],
|
||||
sessionsReady: true,
|
||||
copies: [],
|
||||
flushed: [],
|
||||
failFlush: false
|
||||
}
|
||||
jarOf(LOCAL_DEFAULT).set('sid', 'desktop-login')
|
||||
jarOf(LOCAL_WORK).set('work-sid', 'work-login')
|
||||
jarOf(SSH_PARTITION).set('ssh-sid', 'ssh-login')
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
resetWslMoveDesktopRewriteStoresForTests()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function snapshot(partitions: readonly string[]): Record<string, [string, string][]> {
|
||||
return Object.fromEntries(partitions.map((partition) => [partition, [...jarOf(partition)]]))
|
||||
}
|
||||
|
||||
describe('WSL move browser-cookies store', () => {
|
||||
it('copies every browser profile into the WSL host partition and records it', async () => {
|
||||
registerWslMoveBrowserCookieStore(dependencies())
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
const untouched = snapshot([LOCAL_DEFAULT, LOCAL_WORK, SSH_PARTITION])
|
||||
|
||||
await runWslMoveDesktopRewritePass({ userDataPath })
|
||||
|
||||
const defaultHost = hostPartitionOf('default')
|
||||
const workHost = hostPartitionOf('work')
|
||||
expect(desktop.copies).toEqual([
|
||||
{ source: LOCAL_DEFAULT, destination: defaultHost },
|
||||
{ source: LOCAL_WORK, destination: workHost }
|
||||
])
|
||||
expect(jarOf(defaultHost).get('sid')).toBe('desktop-login')
|
||||
expect(jarOf(workHost).get('work-sid')).toBe('work-login')
|
||||
expect(desktop.flushed.sort()).toEqual([defaultHost, workHost].sort())
|
||||
// Sources and every other host's jar are only ever read.
|
||||
expect(snapshot([LOCAL_DEFAULT, LOCAL_WORK, SSH_PARTITION])).toEqual(untouched)
|
||||
expect(record()?.rewrittenStores).toEqual([WSL_MOVE_BROWSER_COOKIES_STORE_ID])
|
||||
expect(record()?.phase).toBe('client-rewritten')
|
||||
expect(record()?.browserCookieCopy).toMatchObject({
|
||||
outcome: 'copied',
|
||||
profiles: [
|
||||
{ browserProfileId: 'default', destinationPartition: defaultHost, alreadyCopied: false },
|
||||
{ browserProfileId: 'work', destinationPartition: workHost, alreadyCopied: false }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'a WSL environment without its deployment link',
|
||||
environments: () => [wslHost('unlinked')],
|
||||
reason: 'no-stable-partition'
|
||||
},
|
||||
{
|
||||
name: 'a destination that is not a WSL host',
|
||||
environments: () => [hostEnvironment('managed-ssh', 'wsl:Ubuntu')],
|
||||
reason: 'destination-not-wsl-host'
|
||||
},
|
||||
{
|
||||
name: 'a removed destination environment',
|
||||
environments: () => [],
|
||||
reason: 'destination-environment-missing'
|
||||
}
|
||||
])('leaves $name on a fresh partition and says why', async ({ environments, reason }) => {
|
||||
desktop.environments = environments()
|
||||
registerWslMoveBrowserCookieStore(dependencies())
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
const before = snapshot([...desktop.jars.keys()])
|
||||
|
||||
await runWslMoveDesktopRewritePass({ userDataPath })
|
||||
|
||||
expect(desktop.copies).toEqual([])
|
||||
expect(snapshot([...desktop.jars.keys()])).toEqual(before)
|
||||
expect(record()?.browserCookieCopy).toEqual({ outcome: 'fresh-partition', reason })
|
||||
expect(record()?.rewrittenStores).toEqual([WSL_MOVE_BROWSER_COOKIES_STORE_ID])
|
||||
})
|
||||
|
||||
it('leaves the host fresh when another identity owns its partition name', async () => {
|
||||
registerWslMoveBrowserCookieStore(
|
||||
dependencies({
|
||||
bindHostPartition: (_storage, browserProfileId) => {
|
||||
if (browserProfileId === 'work') {
|
||||
throw new Error('browser_route_partition_binding_conflict')
|
||||
}
|
||||
return hostPartitionOf(browserProfileId)
|
||||
}
|
||||
})
|
||||
)
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
|
||||
await runWslMoveDesktopRewritePass({ userDataPath })
|
||||
|
||||
expect(desktop.copies).toEqual([])
|
||||
expect(record()?.browserCookieCopy).toEqual({
|
||||
outcome: 'fresh-partition',
|
||||
reason: 'destination-partition-conflict'
|
||||
})
|
||||
})
|
||||
|
||||
it('re-runs idempotently after a crash between the copy and its ledger entry', async () => {
|
||||
registerWslMoveBrowserCookieStore(dependencies())
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
desktop.failFlush = true
|
||||
const warn = vi.fn()
|
||||
|
||||
await runWslMoveDesktopRewritePass({ userDataPath, warn })
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(record()?.rewrittenStores).toEqual([])
|
||||
const defaultHost = hostPartitionOf('default')
|
||||
const afterCrash = snapshot([defaultHost])
|
||||
|
||||
desktop.failFlush = false
|
||||
await runWslMoveDesktopRewritePass({ userDataPath, warn })
|
||||
|
||||
expect(snapshot([defaultHost])).toEqual(afterCrash)
|
||||
expect(record()?.rewrittenStores).toEqual([WSL_MOVE_BROWSER_COOKIES_STORE_ID])
|
||||
const outcome = record()?.browserCookieCopy
|
||||
expect(outcome?.outcome === 'copied' && outcome.profiles[0]?.counts).toMatchObject({
|
||||
copied: 0,
|
||||
skippedPresent: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for browser sessions instead of opening a partition early', async () => {
|
||||
registerWslMoveBrowserCookieStore(dependencies())
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
desktop.sessionsReady = false
|
||||
const warn = vi.fn()
|
||||
|
||||
await runWslMoveDesktopRewritePass({ userDataPath, warn })
|
||||
expect(desktop.copies).toEqual([])
|
||||
expect(record()?.rewrittenStores).toEqual([])
|
||||
expect(warn.mock.calls[0]?.[0]).toContain('wsl_move_browser_sessions_not_ready')
|
||||
|
||||
desktop.sessionsReady = true
|
||||
await runWslMoveDesktopRewritePass({ userDataPath, warn })
|
||||
expect(record()?.rewrittenStores).toEqual([WSL_MOVE_BROWSER_COOKIES_STORE_ID])
|
||||
})
|
||||
|
||||
it('copies once per host: a later move into the same host leaves its jar alone', async () => {
|
||||
registerWslMoveBrowserCookieStore(dependencies())
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
await runWslMoveDesktopRewritePass({ userDataPath })
|
||||
const first = record()
|
||||
if (!first) {
|
||||
throw new Error('the first move has no journal record')
|
||||
}
|
||||
// Advancing keeps the record's outcome, as the journal itself does.
|
||||
seedWslMoveCutover(userDataPath, { ...first, phase: 'retained' })
|
||||
const defaultHost = hostPartitionOf('default')
|
||||
// The user signed out on the host; a second copy must not sign them back in.
|
||||
jarOf(defaultHost).delete('sid')
|
||||
desktop.copies.length = 0
|
||||
|
||||
seedWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-2', 'destination-committed', { supersedesMigrationId: 'move-1' })
|
||||
)
|
||||
await runWslMoveDesktopRewritePass({ userDataPath })
|
||||
|
||||
expect(desktop.copies).toEqual([])
|
||||
expect(jarOf(defaultHost).has('sid')).toBe(false)
|
||||
expect(record('move-2')?.browserCookieCopy).toMatchObject({
|
||||
outcome: 'copied',
|
||||
profiles: [{ alreadyCopied: true }, { alreadyCopied: true }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing without a move journal', async () => {
|
||||
const sessionsReady = vi.fn(() => true)
|
||||
const copyCookies = vi.fn()
|
||||
registerWslMoveBrowserCookieStore(dependencies({ sessionsReady, copyCookies }))
|
||||
|
||||
await runWslMoveDesktopRewritePass({ userDataPath })
|
||||
|
||||
expect(sessionsReady).not.toHaveBeenCalled()
|
||||
expect(copyCookies).not.toHaveBeenCalled()
|
||||
expect(listWslMoveCutovers(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('does nothing while the internal WSL hosts gate is closed', async () => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
|
||||
const copyCookies = vi.fn()
|
||||
registerWslMoveBrowserCookieStore(dependencies({ copyCookies }))
|
||||
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
|
||||
|
||||
await runGatedWslMoveDesktopRewritePass({ userDataPath })
|
||||
|
||||
expect(copyCookies).not.toHaveBeenCalled()
|
||||
expect(record()?.rewrittenStores).toEqual([])
|
||||
})
|
||||
|
||||
it('still reads a journal whose outcome a newer build wrote', () => {
|
||||
const parsed = parseWslMoveCutover({
|
||||
...cutover('move-1', 'destination-committed'),
|
||||
browserCookieCopy: { outcome: 'fresh-partition', reason: 'a-reason-from-the-future' }
|
||||
})
|
||||
expect(parsed.migrationId).toBe('move-1')
|
||||
expect(parsed.browserCookieCopy).toBeUndefined()
|
||||
})
|
||||
|
||||
it('previews the reason a move would start the host fresh', () => {
|
||||
const resolve = dependencies()
|
||||
expect(previewWslMoveBrowserCookieCopy(resolve, 'orca-profile-a', wslHost())).toEqual({
|
||||
kind: 'copy'
|
||||
})
|
||||
expect(previewWslMoveBrowserCookieCopy(resolve, 'orca-profile-a', wslHost('unlinked'))).toEqual(
|
||||
{
|
||||
kind: 'fresh-partition',
|
||||
reason: 'no-stable-partition'
|
||||
}
|
||||
)
|
||||
expect(previewWslMoveBrowserCookieCopy(resolve, 'orca-profile-a', undefined)).toEqual({
|
||||
kind: 'fresh-partition',
|
||||
reason: 'destination-environment-missing'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* The `browser-cookies` rewrite store (owner decision 3): a committed WSL move copies the
|
||||
* desktop's in-app browser cookies once into the WSL host's route partition, or records why the
|
||||
* host starts on a fresh partition instead. Browser profiles are global, not per project, so each
|
||||
* profile's whole jar is copied, once per host partition, by the first move into that host.
|
||||
*/
|
||||
import type { KnownRuntimeEnvironment } from '../../../shared/runtime-environments'
|
||||
import {
|
||||
emptyWslMoveBrowserCookieCopyCounts,
|
||||
WSL_MOVE_BROWSER_COOKIE_COPY_MAX_PROFILES,
|
||||
WSL_MOVE_BROWSER_COOKIES_STORE_ID,
|
||||
type WslMoveBrowserCookieCopy,
|
||||
type WslMoveBrowserCookieCopyCounts,
|
||||
type WslMoveBrowserCookieFreshPartitionReason
|
||||
} from '../../../shared/wsl-move/wsl-move-browser-cookie-copy'
|
||||
import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
import type {
|
||||
WslHostBrowserRouteStorage,
|
||||
WslHostBrowserRouteStorageResult
|
||||
} from '../../browser/wsl-host-browser-route-partition'
|
||||
import { listWslMoveCutovers, writeWslMoveCutover } from './wsl-move-cutover-journal'
|
||||
import type { WslMoveDesktopRewriteContext } from './wsl-move-desktop-rewrite-stores'
|
||||
|
||||
export type WslMoveBrowserCookieCopyDependencies = {
|
||||
userDataPath: string
|
||||
/**
|
||||
* False until a partition may be opened (after the pending cookie import replay) and an app
|
||||
* window exists, so destroying the copy's hidden windows never closes the last one.
|
||||
*/
|
||||
sessionsReady: () => boolean
|
||||
orcaProfileId: () => string | null
|
||||
listEnvironments: () => readonly KnownRuntimeEnvironment[]
|
||||
listBrowserProfiles: () => readonly { id: string; partition: string }[]
|
||||
resolveHostStorage: (
|
||||
orcaProfileId: string,
|
||||
environment: KnownRuntimeEnvironment
|
||||
) => WslHostBrowserRouteStorageResult
|
||||
bindHostPartition: (storage: WslHostBrowserRouteStorage, browserProfileId: string) => string
|
||||
copyCookies: (
|
||||
sourcePartition: string,
|
||||
destinationPartition: string
|
||||
) => Promise<WslMoveBrowserCookieCopyCounts>
|
||||
flushCookies: (partition: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type WslMoveBrowserCookieCopyPreview =
|
||||
| { kind: 'copy' }
|
||||
| { kind: 'fresh-partition'; reason: WslMoveBrowserCookieFreshPartitionReason }
|
||||
|
||||
/** What a move into `environment` would do with the logins; the move preview shows the reason. */
|
||||
export function previewWslMoveBrowserCookieCopy(
|
||||
dependencies: Pick<WslMoveBrowserCookieCopyDependencies, 'resolveHostStorage'>,
|
||||
orcaProfileId: string,
|
||||
environment: KnownRuntimeEnvironment | undefined
|
||||
): WslMoveBrowserCookieCopyPreview {
|
||||
if (!environment) {
|
||||
return { kind: 'fresh-partition', reason: 'destination-environment-missing' }
|
||||
}
|
||||
const storage = dependencies.resolveHostStorage(orcaProfileId, environment)
|
||||
return storage.kind === 'stable'
|
||||
? { kind: 'copy' }
|
||||
: { kind: 'fresh-partition', reason: storage.reason }
|
||||
}
|
||||
|
||||
/**
|
||||
* The store's `run`. Idempotent: a crash after the copy but before the ledger entry re-runs it,
|
||||
* and the merge then finds every cookie already present. The source partition is only read.
|
||||
* Returns the partitions written, which the store's `flush` makes durable.
|
||||
*/
|
||||
export async function runWslMoveBrowserCookieCopy(
|
||||
dependencies: WslMoveBrowserCookieCopyDependencies,
|
||||
context: Pick<WslMoveDesktopRewriteContext, 'cutover'>
|
||||
): Promise<string[]> {
|
||||
if (!dependencies.sessionsReady()) {
|
||||
// No ledger entry: the pass after the first window shows retries it.
|
||||
throw new Error('wsl_move_browser_sessions_not_ready')
|
||||
}
|
||||
const orcaProfileId = dependencies.orcaProfileId()
|
||||
if (!orcaProfileId) {
|
||||
throw new Error('wsl_move_browser_sessions_not_ready')
|
||||
}
|
||||
const { cutover } = context
|
||||
const environment = dependencies
|
||||
.listEnvironments()
|
||||
.find((entry) => entry.id === cutover.destinationEnvironmentId)
|
||||
if (!environment) {
|
||||
recordOutcome(dependencies.userDataPath, cutover.migrationId, {
|
||||
outcome: 'fresh-partition',
|
||||
reason: 'destination-environment-missing'
|
||||
})
|
||||
return []
|
||||
}
|
||||
const storage = dependencies.resolveHostStorage(orcaProfileId, environment)
|
||||
if (storage.kind !== 'stable') {
|
||||
recordOutcome(dependencies.userDataPath, cutover.migrationId, {
|
||||
outcome: 'fresh-partition',
|
||||
reason: storage.reason
|
||||
})
|
||||
return []
|
||||
}
|
||||
const profiles = dependencies.listBrowserProfiles()
|
||||
let destinations: string[]
|
||||
try {
|
||||
// Every binding before any copy, so a conflict copies nothing; bindings match what a page makes.
|
||||
destinations = profiles.map((profile) =>
|
||||
dependencies.bindHostPartition(storage.storage, profile.id)
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'browser_route_partition_binding_conflict') {
|
||||
recordOutcome(dependencies.userDataPath, cutover.migrationId, {
|
||||
outcome: 'fresh-partition',
|
||||
reason: 'destination-partition-conflict'
|
||||
})
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const alreadyCopied = partitionsCopiedByEarlierMoves(dependencies.userDataPath, cutover)
|
||||
const written: string[] = []
|
||||
const results: Extract<WslMoveBrowserCookieCopy, { outcome: 'copied' }>['profiles'] = []
|
||||
for (const [index, profile] of profiles.entries()) {
|
||||
const destinationPartition = destinations[index]
|
||||
if (!destinationPartition) {
|
||||
continue
|
||||
}
|
||||
// Why once per partition: a second copy would revive logins the user signed out of there.
|
||||
if (alreadyCopied.has(destinationPartition)) {
|
||||
results.push({
|
||||
browserProfileId: profile.id,
|
||||
destinationPartition,
|
||||
alreadyCopied: true,
|
||||
counts: emptyWslMoveBrowserCookieCopyCounts()
|
||||
})
|
||||
continue
|
||||
}
|
||||
const counts = await dependencies.copyCookies(profile.partition, destinationPartition)
|
||||
written.push(destinationPartition)
|
||||
results.push({
|
||||
browserProfileId: profile.id,
|
||||
destinationPartition,
|
||||
alreadyCopied: false,
|
||||
counts
|
||||
})
|
||||
}
|
||||
recordOutcome(dependencies.userDataPath, cutover.migrationId, {
|
||||
outcome: 'copied',
|
||||
// Within the journal's bound, so a huge profile list never blocks the ledger entry.
|
||||
profiles: results.slice(0, WSL_MOVE_BROWSER_COOKIE_COPY_MAX_PROFILES)
|
||||
})
|
||||
return written
|
||||
}
|
||||
|
||||
/** Partitions another move durably copied into: its ledger already holds this store. */
|
||||
function partitionsCopiedByEarlierMoves(
|
||||
userDataPath: string,
|
||||
cutover: Readonly<WslMoveCutover>
|
||||
): Set<string> {
|
||||
const partitions = new Set<string>()
|
||||
for (const entry of listWslMoveCutovers(userDataPath)) {
|
||||
const record = entry.browserCookieCopy
|
||||
if (
|
||||
entry.migrationId === cutover.migrationId ||
|
||||
entry.destinationEnvironmentId !== cutover.destinationEnvironmentId ||
|
||||
!entry.rewrittenStores.includes(WSL_MOVE_BROWSER_COOKIES_STORE_ID) ||
|
||||
record?.outcome !== 'copied'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
for (const profile of record.profiles) {
|
||||
partitions.add(profile.destinationPartition)
|
||||
}
|
||||
}
|
||||
return partitions
|
||||
}
|
||||
|
||||
/** Written before the ledger entry, from a fresh read, so the driver's append keeps it. */
|
||||
function recordOutcome(
|
||||
userDataPath: string,
|
||||
migrationId: string,
|
||||
browserCookieCopy: WslMoveBrowserCookieCopy
|
||||
): void {
|
||||
const current = listWslMoveCutovers(userDataPath).find(
|
||||
(entry) => entry.migrationId === migrationId
|
||||
)
|
||||
if (current?.phase !== 'destination-committed') {
|
||||
throw new Error('wsl_move_record_missing')
|
||||
}
|
||||
writeWslMoveCutover(userDataPath, {
|
||||
...current,
|
||||
browserCookieCopy,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Registers the `browser-cookies` rewrite store against the app's real browser sessions. */
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { listEnvironments } from '../../../shared/runtime-environment-store'
|
||||
import { WSL_MOVE_BROWSER_COOKIES_STORE_ID } from '../../../shared/wsl-move/wsl-move-browser-cookie-copy'
|
||||
import {
|
||||
copyBrowserPartitionCookies,
|
||||
flushBrowserPartitionCookies
|
||||
} from '../../browser/browser-partition-cookie-copy'
|
||||
import { activeBrowserRoutePartitionOrcaProfileId } from '../../browser/browser-route-partition-binding-runtime'
|
||||
import { browserSessionRegistry } from '../../browser/browser-session-registry'
|
||||
import { browserSessionsInitialized } from '../../browser/browser-session-startup'
|
||||
import {
|
||||
bindWslHostBrowserRoutePartition,
|
||||
wslHostBrowserRouteStorage
|
||||
} from '../../browser/wsl-host-browser-route-partition'
|
||||
import {
|
||||
runWslMoveBrowserCookieCopy,
|
||||
type WslMoveBrowserCookieCopyDependencies
|
||||
} from './wsl-move-browser-cookie-copy'
|
||||
import { registerWslMoveDesktopRewriteStore } from './wsl-move-desktop-rewrite-stores'
|
||||
|
||||
export function productionWslMoveBrowserCookieCopyDependencies(
|
||||
userDataPath: string
|
||||
): WslMoveBrowserCookieCopyDependencies {
|
||||
return {
|
||||
userDataPath,
|
||||
// Why a window: win32 quits on window-all-closed, which the copy's hidden windows would trigger.
|
||||
sessionsReady: () =>
|
||||
browserSessionsInitialized() &&
|
||||
BrowserWindow.getAllWindows().some((window) => !window.isDestroyed()),
|
||||
orcaProfileId: activeBrowserRoutePartitionOrcaProfileId,
|
||||
listEnvironments: () => listEnvironments(userDataPath),
|
||||
listBrowserProfiles: () => browserSessionRegistry.listProfiles(),
|
||||
resolveHostStorage: wslHostBrowserRouteStorage,
|
||||
bindHostPartition: bindWslHostBrowserRoutePartition,
|
||||
copyCookies: copyBrowserPartitionCookies,
|
||||
flushCookies: flushBrowserPartitionCookies
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register last among main stores: until browser sessions and a window exist it fails, which pauses
|
||||
* the stores after it in that pass too. The pass started once the first window shows finishes it.
|
||||
*/
|
||||
export function registerWslMoveBrowserCookieStore(
|
||||
dependencies: WslMoveBrowserCookieCopyDependencies
|
||||
): void {
|
||||
let unflushed: string[] = []
|
||||
registerWslMoveDesktopRewriteStore({
|
||||
id: WSL_MOVE_BROWSER_COOKIES_STORE_ID,
|
||||
side: 'main',
|
||||
run: async (context) => {
|
||||
unflushed = await runWslMoveBrowserCookieCopy(dependencies, context)
|
||||
},
|
||||
flush: async () => {
|
||||
await Promise.all(unflushed.map((partition) => dependencies.flushCookies(partition)))
|
||||
unflushed = []
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* One spelling for a WSL folder across UNC aliases (`wsl$`, `wsl.localhost`), distro case, Linux
|
||||
* spelling and trailing separators, so the exit proof and the spawn fence agree on what is in scope.
|
||||
*/
|
||||
import { foldWslUncPathCaseInsensitiveParts, toWindowsWslUncPath } from '../../../shared/wsl-paths'
|
||||
|
||||
/** Null for a path that is not in a WSL distro (a Windows drive path, say). */
|
||||
export function comparableWslPath(path: string, distro: string): string | null {
|
||||
const unc = isLinuxSpelled(path) ? toWindowsWslUncPath(path, distro) : path
|
||||
return foldWslUncPathCaseInsensitiveParts(unc)?.replace(/\/+$/, '') ?? null
|
||||
}
|
||||
|
||||
export function isLinuxSpelled(path: string): boolean {
|
||||
return /^\/(?!\/)/.test(path)
|
||||
}
|
||||
|
||||
export function isAtOrUnder(path: string, root: string): boolean {
|
||||
return path === root || path.startsWith(`${root}/`)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { wslMoveCopyBytes } from './wsl-move-driver-transfer'
|
||||
|
||||
describe('wslMoveCopyBytes', () => {
|
||||
it('counts the terminal history sent while staging, not only the blobs', () => {
|
||||
// A 262 KB move showed 413 B.
|
||||
const cutover = wslMoveCutoverFixture('move-1', 'destination-staged')
|
||||
const withHistory = {
|
||||
manifest: {
|
||||
...cutover.manifest,
|
||||
payload: {
|
||||
...cutover.manifest.payload,
|
||||
dormantState: {
|
||||
...cutover.manifest.payload.dormantState!,
|
||||
terminalScrollbackSnapshots: [
|
||||
{ tabId: 't', leafId: 'l', ref: 'r', sha256: '0'.repeat(64), byteLength: 261_834 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(wslMoveCopyBytes(withHistory, [{ byteLength: 413 }])).toEqual({
|
||||
snapshotBytes: 261_834,
|
||||
bytesTotal: 262_247
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user