diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index cdcc9941bf05..75197103ce15 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1709,14 +1709,14 @@ "providers": ["local", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "ssh"], - "coverageNotes": "Drives the real Store against a temp profile, the real remoteWorkspace:setForConnectedTargets handler, the real session projection and the real pull-side merge. The relay transport is faked at the multiplexer boundary, so no live SSH host or relay is exercised; the partition routing and projection code under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.", + "coverageNotes": "Drives the real Store against a temp profile, main's real session-write export trigger, the real session projection and the real pull-side merge. The relay transport is faked at the multiplexer boundary, so no live SSH host or relay is exercised; the partition routing and projection code under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/12721", "https://github.com/stablyai/orca/issues/18173", "https://github.com/stablyai/orca/blob/main/src/shared/workspace-session-partition-owner.ts" ], "invariant": "Every workspace the `ssh:` partition names is hydrated at boot and published to the host, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it. Adoption never destroys what it was not told about: a host row the partition holds nothing for cannot replace a populated base row, and a bare id the read found contested is gap-filled rather than replaced, because that is exactly the id where `local` and `ssh:` are not one workspace written twice. Tab-, pane- and file-keyed rows are recovered through the same indexes the split routed them by. An explicit empty tab row written by this build survives the round trip as a present empty row and is declined by the shared seeding predicate. Boot enumerates the partitions persistence actually holds rather than inferring them from the repo catalog, so a target whose only workspace is a folder is read like any other; a folder workspace routes to the same partition main's RuntimeWorkspaceSessionController writes it to, so one workspace is never written into two stores; and a workspace adopted out of a partition routes back to that partition even on a boot whose repo catalog cannot name the host, so the write cannot re-strand what the read just reunited. `contested` means a repo id the catalog registers on more than one host, or two SSH partitions naming one id - never the mere co-presence of 'local' and `ssh:`, which is the repair's own input.", - "oracle": "Assert the renderer routes an SSH folder workspace to `ssh:`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", + "oracle": "Assert the renderer routes an SSH folder workspace to `ssh:`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:` holds the real one, with a second populated SSH partition present. Agree with the host, write the runtime-authored tabs into `ssh:`, and assert main's export carries them to the host snapshot rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts" ], @@ -8559,13 +8559,13 @@ "invariant": "Within one terminal tab, each PTY has at most one layout leaf, pane manager surface, and xterm renderer owner. Restore and remote mirroring must normalize duplicate ownership before replay while retaining active-leaf focus, scrollback, pane authority, agent metadata, and distinct sibling PTYs.", "oracle": "Launch Orca with a real daemon PTY running a synchronized full-screen stream, close the desktop client without killing the daemon process, seed the persisted tab layout with two leaves bound to that PTY, and relaunch. Hide the restored tab behind a sibling and reveal it, then require the stream to remain visible with exactly one manager pane, one xterm DOM node, one root leaf, one PTY binding, and one unique PTY. Unit contracts require the same one-owner result through persisted replay, store hydration, remote mirroring, rootless and repeated-leaf layouts, and deeply nested input.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/shared/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts", - "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "src/shared/terminal-layout-pty-ownership-depth.test.ts", "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts", "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts", "src/renderer/src/runtime/web-session-tabs-sync.test.ts", @@ -8580,7 +8580,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "file": "src/shared/terminal-layout-pty-ownership-depth.test.ts", "assertions": ["prunes deeply nested duplicate ownership without recursive stack growth"] }, { @@ -8611,7 +8611,7 @@ "date": "2026-08-01", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/shared/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", "result": "passed", "durationSeconds": 1.33, "summary": "Five focused files passed 92 ownership, replay, hydration, depth, and remote-mirroring tests." @@ -13716,7 +13716,7 @@ "providers": ["ssh", "remote-runtime", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["ssh", "remote-runtime"], - "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, superseded readiness and placement waits release immediately, delayed worktree placement keeps only the latest arrival's waiter per target, stale arrivals cannot hydrate or publish push status, an incoming snapshot revokes replace-session upload authority before preparation yields, already-captured uploads revalidate that exact authority after local persistence settles, and main rejects their applied revision when a newer host snapshot arrives before IPC admission or while queued. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, superseded readiness and placement waits release immediately, delayed worktree placement keeps only the latest arrival's waiter per target, stale arrivals cannot hydrate or publish push status, main exports only under the revision and host observation the window last agreed on and rejects an export when a newer host snapshot arrives before admission or while queued, and with two desktops on one relay an import exports nothing while a local split exports once. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-3107", "https://github.com/stablyai/orca/pull/12664", @@ -13735,14 +13735,14 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-cache.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", @@ -13797,7 +13797,7 @@ "src/renderer/src/hooks/direct-ssh-state-routing.test.ts", "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts", - "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx", + "src/main/ipc/remote-workspace-two-desktops.test.ts", "src/main/ipc/remote-workspace-cache.test.ts", "src/main/ipc/remote-workspace.test.ts", "src/main/ipc/remote-workspace-patch-queue.test.ts", @@ -14128,13 +14128,10 @@ ] }, { - "file": "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx", + "file": "src/main/ipc/remote-workspace-two-desktops.test.ts", "assertions": [ - "an unsolicited snapshot synchronously revokes its target's upload authority before preparation awaits", - "a session write while snapshot capture is pending cannot replace the host's newly cached tabs", - "an upload captured before snapshot arrival is revalidated after its pending local write and cannot overwrite the incoming revision", - "same-lineage local writes remain uploadable when an earlier overlapping result advances the renderer's acknowledged revision", - "a transient unavailable result retains its observed host token and revision so the next local edit retries" + "with two desktops on one relay, a local split exports exactly once and the peer's import plus its save-back export nothing", + "a desktop whose import conflicted exports nothing until a whole pull agrees again" ] }, { @@ -14225,13 +14222,13 @@ "summary": "Three main-process remote-workspace files and 17 tests passed, including token-A rejection after same-revision observations before admission and while queued, eviction/refetch fail-closed behavior, same-client overlap at bases 7 then 8, revision-zero compatibility, reset-relay fallback, and bounded cache lineage." }, { - "date": "2026-08-29", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 10.44, - "summary": "Three renderer remote-workspace files and 28 tests passed, including acknowledged observation-token propagation, incoming-lineage upload cancellation, same-lineage overlapping upload continuation, transient-unavailable retry authority, and latest-only snapshot arrival fencing." + "durationSeconds": 2, + "summary": "Three files and 22 tests passed after main took over SSH workspace export, including two desktops on one relay where an import and its save-back export nothing, a local split exports once, and a conflicted import keeps that desktop out of exports." }, { "date": "2026-08-29", @@ -14243,13 +14240,13 @@ "summary": "Two focused files and 26 tests passed, including pane-scoped rendering and recovery, sibling-error retention, split-close pane-id churn cleanup, current-transport multi-error clearing, dismissal re-admission, multi-line deduplication, and 8-message/24-line/4,000-character bounds." }, { - "date": "2026-08-29", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 10.7, - "summary": "Fourteen direct-SSH files and 155 tests passed, including 32-arrival placement and readiness bursts with one listener/timer maximum, immediate supersession and stop cleanup, latest-only hydration, stale-push fencing, pending-capture and in-flight-write upload exclusion, and existing retry, authority, hydration, split-pane, and detach contracts." + "durationSeconds": 9, + "summary": "Fourteen direct-SSH files and 160 tests passed after main took over SSH workspace export, including stale-push fencing, revision-zero seeding through main, and the existing retry, authority, hydration, split-pane and detach contracts." }, { "date": "2026-08-23", @@ -14288,13 +14285,13 @@ "summary": "Eleven fault-injection and recovery-UI files and 127 tests passed, covering suspended heartbeat clocks, stale socket, PTY, and create generations, canonical pre-ready close recovery with one replacement subscription, cold and repeated PTY reattachment, authoritative health, bounded PTY and terminal-create recovery, post-probe timeout clipping, manually retryable create cutoff, accurate capability-probe failures, same-PTY manual reconnect, pane-state cleanup, fatal error deduplication, stable create identity, cross-process PTY adoption, and fail-closed legacy inventory." }, { - "date": "2026-07-28", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 15.8, - "summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning." + "durationSeconds": 9, + "summary": "Fourteen direct-SSH files and 160 tests passed after main took over SSH workspace export, including stale-push fencing, revision-zero seeding through main, and the existing retry, authority, hydration, split-pane and detach contracts." }, { "date": "2026-07-28", diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 41039fa66f34..97fbc81205c9 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -7294,7 +7294,7 @@ "src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts": 62, "src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts": 39, "src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx": 328, - "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts": 103, + "src/shared/terminal-layout-pty-ownership-depth.test.ts": 103, "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts": 111, "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts": 1448, "src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts": 226, diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 64fd1a0181aa..4d6da370d9f4 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -170,6 +170,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-store-transactions.test.ts', 'src/main/runtime/agent-session-unsaved-creation-supersession.test.ts', 'src/main/runtime/automation-change-publication.test.ts', + 'src/main/runtime/headless-editor-tab-close-persistence.test.ts', + 'src/main/runtime/headless-terminal-tab-order-persistence.test.ts', 'src/main/runtime/host-terminal-close-persistence-durability.test.ts', 'src/main/runtime/orca-runtime-terminal-close-records.test.ts', 'src/main/runtime/orca-runtime-terminal-surface-close.test.ts', @@ -201,6 +203,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', 'src/main/runtime/structured-claude-attachment-grant.test.ts', 'src/main/runtime/structured-claude-pending-rewind.test.ts', + 'src/main/legacy-terminal-surface-tombstones-write.test.ts', 'src/main/ssh-expired-lease-pane-readoption.test.ts', 'src/main/ssh-reattach-pane-cardinality.test.ts', 'src/main/ssh/orcad-migration-cutover-coordinator.test.ts', @@ -211,6 +214,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ssh/orcad-runtime-conversion.test.ts', 'src/main/ssh/orcad-unreachable-setup-release.test.ts', 'src/main/ssh/ssh-target-orcad-preflight.test.ts', + 'src/main/terminal-topology-main-writer-pushes.test.ts', 'src/main/worktree-identity-persistence.test.ts', 'src/main/worktree-removal-close-records.test.ts', 'src/main/worktree-removal-session-partition-fencing.test.ts', diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts index d8a8a54161e0..cd2d9b7f89ec 100644 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ b/src/main/agent-hooks/first-work-folder-rename.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { + TerminalTopologyPublisher, + type TerminalTopologyOwners +} from '../runtime/terminal-topology-publisher' import { renameWorktreeFolderOnFirstWork, type FirstWorkFolderRenameDeps @@ -97,4 +102,52 @@ describe('renameWorktreeFolderOnFirstWork', () => { expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false) expect(deps.moveWorktree).not.toHaveBeenCalled() }) + + it("tells the window about the rename before main pushes either id's topology", async () => { + const NEW_ID = 'repo1::/ws/worktree-creation-spinner' + const tab = (worktreeId: string) => ({ + id: 'tab', + ptyId: 'pty', + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + }) + const ownedBy = (worktreeId: string): TerminalTopologyOwners => + new Map([ + [ + worktreeId, + { + hostId: 'local', + session: { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [tab(worktreeId)] } + } + } + ] + ]) + let owners = ownedBy(OLD_ID) + const order: string[] = [] + const publisher = new TerminalTopologyPublisher( + () => owners, + (slice) => order.push(`${slice.worktreeId}: ${slice.tabs.length} tab(s)`) + ) + publisher.snapshot() + order.length = 0 + + const deps = makeDeps({ + migrateWorktreeIdentity: vi.fn(() => { + owners = ownedBy(NEW_ID) + publisher.markDirty() + }), + notifyWorktreeRenamed: vi.fn(() => order.push('renamed')) + }) + await renameWorktreeFolderOnFirstWork(OLD_ID, 'worktree-creation-spinner', deps) + await Promise.resolve() + + // The window re-keys its tabs first, so the old id's empty slice finds none to drop. + expect(order).toEqual(['renamed', `${OLD_ID}: 0 tab(s)`, `${NEW_ID}: 1 tab(s)`]) + }) }) diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 7a72c0d53b49..179803f2ca23 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -109,6 +109,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), @@ -171,8 +172,9 @@ describe('registerPtyHandlers', () => { 'inc-proven-absent-owner', { hostExitConfirmed: true } ) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() - expect(store.flushOrThrow).toHaveBeenCalledOnce() + // The pane, its binding and its row stay for the fresh spawn's bind to swap. + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(store.retirePtyBinding).not.toHaveBeenCalled() }) it('does not poll after the routed provider confirms absence', async () => { const worktreeId = 'repo-1::/tmp/probe-blip-owner' @@ -233,6 +235,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), @@ -357,6 +360,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 86fe3381f2bf..933112763940 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -159,7 +159,6 @@ describe('registerPtyHandlers', () => { leafId, ptyId: expect.any(String), incarnationId: expect.any(String), - hostAdmittedMembership: true, origin: 'spawn' }) }) @@ -519,7 +518,6 @@ describe('registerPtyHandlers', () => { leafId, ptyId: 'pty-shared', startupCwd: '/tmp', - hostAdmittedMembership: true, origin: 'spawn' }) }) diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index 30ef457a81d3..893330ad9ffe 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -123,6 +123,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), upsertSshRemotePtyLease: vi.fn(), supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), @@ -184,16 +185,16 @@ describe('registerPtyHandlers', () => { expect(remoteSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume exact-dead-ssh-provider-session' }) - expect(store.setWorkspaceSession).toHaveBeenCalledWith(expect.anything(), hostId) - expect(store.persistPtyBinding).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId, - tabId, - leafId, - ptyId: freshPtyId - }), - hostId - ) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + // The fresh spawn replaces the absent owner, so its bind swaps the binding naming it. + const [input, persistHostId] = store.persistPtyBinding.mock.calls[0] ?? [] + expect(persistHostId).toBe(hostId) + expect(typeof input === 'function' ? input() : input).toMatchObject({ + worktreeId, + tabId, + leafId, + ptyId: freshPtyId + }) } finally { unregisterSshPtyProvider(connectionId) } @@ -542,7 +543,6 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-1@@relay-pty', - hostAdmittedMembership: true, origin: 'spawn' }, 'ssh:ssh-1' diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index c057d6ec5d21..9cf6e42cc5cf 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -279,6 +279,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => ({ id: 'dead-persisted-owner', @@ -350,7 +351,7 @@ describe('registerPtyHandlers', () => { sessionId: 'pty-dead-persisted-owner', command: undefined }) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', -1, @@ -374,8 +375,8 @@ describe('registerPtyHandlers', () => { expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume exact-dead-provider-session' }) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() - expect(store.flushOrThrow).toHaveBeenCalledOnce() + // The pane and its binding stay for the fresh spawn's bind to swap. + expect(store.setWorkspaceSession).not.toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', -1, @@ -441,6 +442,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), diff --git a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts index b66cd30e71fe..a83720f8f0fc 100644 --- a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts +++ b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts @@ -202,7 +202,6 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-reattach-ok@@relay-pty', - hostAdmittedMembership: true, origin: 'reattach' }, 'ssh:ssh-reattach-ok' diff --git a/src/main/ipc/pty-spawn-placement-threading.test.ts b/src/main/ipc/pty-spawn-placement-threading.test.ts index 76b6dd9ce87b..2d31198264bc 100644 --- a/src/main/ipc/pty-spawn-placement-threading.test.ts +++ b/src/main/ipc/pty-spawn-placement-threading.test.ts @@ -81,7 +81,9 @@ describe('pty spawn placement threading', () => { noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), - onPtyData: vi.fn() + onPtyData: vi.fn(), + // Names a push only once the binding is written. + settleTerminalTopology: vi.fn(() => (store.persistPtyBinding.mock.calls.length > 0 ? 9 : 0)) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn reads only the window, runtime and store members these fakes define. const args = [ @@ -104,8 +106,8 @@ describe('pty spawn placement threading', () => { } } - async function ipcSpawn(extra: Record): Promise { - await handlers.get('pty:spawn')!(null, { + async function ipcSpawn(extra: Record): Promise { + return await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp', @@ -142,6 +144,11 @@ describe('pty spawn placement threading', () => { ]) }) + it('replies with the publishSeq of the push that carries the binding', async () => { + register() + await expect(ipcSpawn({ placement: SPLIT })).resolves.toMatchObject({ publishSeq: 9 }) + }) + it('drops a malformed or future-kind placement instead of failing the spawn', async () => { const { store } = register() await ipcSpawn({ placement: { kind: 'floating-window', anchor: 'x' } }) @@ -170,7 +177,6 @@ describe('pty spawn placement threading', () => { leafId: LEAF, ptyId: expect.any(String), incarnationId: expect.any(String), - hostAdmittedMembership: true, placement: SPLIT, origin: 'spawn' }) diff --git a/src/main/ipc/pty/ipc/leaf-move.test.ts b/src/main/ipc/pty/ipc/leaf-move.test.ts index 724a706a56bb..70c9ca9a1581 100644 --- a/src/main/ipc/pty/ipc/leaf-move.test.ts +++ b/src/main/ipc/pty/ipc/leaf-move.test.ts @@ -3,7 +3,8 @@ import { agentHookServer } from '../../../agent-hooks/server' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { TerminalLeafMoveResult } from '../../../../shared/terminal-leaf-move' -import { commitLeafMoveAndRekey } from './leaf-move' +import { setPtyHostBindings } from '../../pty-host-bindings' +import { commitLeafMoveAndRekey, installPtyLeafMoveIpcHandler } from './leaf-move' const LEAF = '22222222-2222-4222-8222-222222222222' const request = { @@ -14,19 +15,22 @@ const request = { ptyId: 'pty-agent' } -function deps(result: TerminalLeafMoveResult) { +function deps(result: TerminalLeafMoveResult, homeHostId: string | null = 'ssh:target-1') { const rekeyWorkerTerminalResourcePaneKey = vi.fn(() => 1) + const moveTerminalLeafToNewTab = vi.fn(async () => result) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only this Store method. - const store = { moveTerminalLeafToNewTab: vi.fn(async () => result) } as unknown as Store - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only the orchestration DB accessor. + const store = { moveTerminalLeafToNewTab } as unknown as Store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only the home resolver and the orchestration DB accessor. const runtime = { + getTerminalTopologyHomeHostId: () => homeHostId, getExistingOrchestrationDb: () => ({ rekeyWorkerTerminalResourcePaneKey }) } as unknown as OrcaRuntimeService - return { store, runtime, rekeyWorkerTerminalResourcePaneKey } + return { store, runtime, rekeyWorkerTerminalResourcePaneKey, moveTerminalLeafToNewTab } } afterEach(() => { vi.restoreAllMocks() + setPtyHostBindings({}) }) describe('pty:moveLeafToNewTab', () => { @@ -67,4 +71,54 @@ describe('pty:moveLeafToNewTab', () => { expect(transfer).not.toHaveBeenCalled() expect(rekeyWorkerTerminalResourcePaneKey).not.toHaveBeenCalled() }) + + it("writes only the worktree's home partition", async () => { + vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const { store, runtime, moveTerminalLeafToNewTab } = deps({ status: 'moved', ptyId: null }) + + await commitLeafMoveAndRekey({ store, runtime }, request) + + expect(moveTerminalLeafToNewTab).toHaveBeenCalledExactlyOnceWith(request, 'ssh:target-1') + }) + + it('writes nothing when the home is unresolved', async () => { + const { store, runtime, moveTerminalLeafToNewTab } = deps( + { status: 'moved', ptyId: null }, + null + ) + + await expect(commitLeafMoveAndRekey({ store, runtime }, request)).resolves.toEqual({ + status: 'refused', + reason: 'home_unresolved' + }) + expect(moveTerminalLeafToNewTab).not.toHaveBeenCalled() + }) + + it('replies with the publishSeq of the push that carries the move', async () => { + vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const handlers = new Map unknown>() + setPtyHostBindings({ + ipc: { + handle: (channel, listener) => handlers.set(channel, listener), + on: () => {}, + removeHandler: () => {}, + removeAllListeners: () => {} + } + }) + const { store, runtime } = deps({ status: 'moved', ptyId: 'pty-agent' }) + const settleTerminalTopology = vi.fn(() => 12) + installPtyLeafMoveIpcHandler({ + store, + runtime: Object.assign(runtime, { settleTerminalTopology }) + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler never reads the IPC event. + const reply = handlers.get('pty:moveLeafToNewTab')!({} as never, request) + await expect(reply).resolves.toEqual({ + status: 'moved', + ptyId: 'pty-agent', + publishSeq: 12 + }) + expect(settleTerminalTopology).toHaveBeenCalledWith(request.worktreeId) + }) }) diff --git a/src/main/ipc/pty/ipc/leaf-move.ts b/src/main/ipc/pty/ipc/leaf-move.ts index c8b0cb9f07a7..3cab0c6959f2 100644 --- a/src/main/ipc/pty/ipc/leaf-move.ts +++ b/src/main/ipc/pty/ipc/leaf-move.ts @@ -17,7 +17,12 @@ export async function commitLeafMoveAndRekey( if (!deps.store) { return { status: 'not_held' } } - const result = await deps.store.moveTerminalLeafToNewTab(request) + // One home per worktree; an unresolved one is unverifiable, so nothing is written. + const hostId = deps.runtime?.getTerminalTopologyHomeHostId(request.worktreeId) + if (!hostId) { + return { status: 'refused', reason: 'home_unresolved' } + } + const result = await deps.store.moveTerminalLeafToNewTab(request, hostId) if (result.status !== 'moved') { return result } @@ -53,6 +58,7 @@ export function installPtyLeafMoveIpcHandler(deps: { if (!isTerminalLeafMoveRequest(args)) { return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLeafMoveResult } - return commitLeafMoveAndRekey(deps, args) + const result = await commitLeafMoveAndRekey(deps, args) + return { ...result, publishSeq: deps.runtime?.settleTerminalTopology(args.worktreeId) } }) } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 4b8d71c04417..894eb73df2c3 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -67,6 +67,8 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis : null ctx.spawnTiming.mark('stable_adoption') if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) { + // The owner's process is absent; this spawn replaces it, so it is not reattached again. + ctx.replacedPaneOwner ??= { ptyId: ctx.earlyStablePaneOwner.ptyId, pane: null } const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) if (pathUsable) { await pathUsable diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts index 6fd5183a36d8..37d43ba37b01 100644 --- a/src/main/ipc/pty/ipc/spawn.ts +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -11,6 +11,7 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { if (startupPromise) { await startupPromise } - return runPtyIpcSpawn(deps, args) + const result = await runPtyIpcSpawn(deps, args) + return { ...result, publishSeq: deps.runtime?.settleTerminalTopology?.(args.worktreeId) } }) } diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 83ebdd50364d..bf0acafc4653 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -265,6 +265,10 @@ export async function attachStablePaneOwner( ) { throw new Error('terminal_pane_owner_changed') } + // Why 'replaced': the fresh spawn that follows takes the pane, so main keeps the pane, its + // binding and the tab row naming it until that spawn's bind swaps them, as for a restart. + const stops = runtime?.intentionalPtyStops + const settleReplacement = stops?.mark(owner.ptyId, 'replaced', owner.incarnationId ?? null) // `pty.attach` answers absent both for a pid the relay probed and found gone and for an id its // session map never had — every id minted before a relay restart, checked against nothing. Only // the marked half observed the process, so only it may certify a death; the rest publishes the @@ -276,17 +280,9 @@ export async function attachStablePaneOwner( owner.incarnationId, isObservedPtyExitEvidence(error) ? { hostExitConfirmed: true } : {} ) + settleReplacement?.(true) clearProviderPtyState(owner.ptyId) ptyOwnership.delete(owner.ptyId) - if ( - args.worktreeId && - !(await retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId)) - ) { - throw new Error('terminal_pane_owner_changed') - } - if (args.resolveOwner?.()) { - throw new Error('terminal_pane_owner_changed') - } return null } if ( diff --git a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts index 390bcdd451a2..7f89e95b5574 100644 --- a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts @@ -11,7 +11,6 @@ import { describe, expect, it, vi } from 'vitest' import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import { getDefaultWorkspaceSession } from '../../../../shared/constants' -import { makePaneKey } from '../../../../shared/stable-pane-id' import { SSH_EXIT_UNCONFIRMED_REASON } from '../../../../shared/pty-liveness-verdict' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { SessionNotFoundError } from '../../../daemon/daemon-errors' @@ -23,7 +22,7 @@ import { } from '../../../providers/ssh-pty-errors' import type { IPtyProvider } from '../../../providers/types' import { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import { resolvePersistedStablePaneOwner, spawnForStablePane } from './stable-owner' +import { spawnForStablePane } from './stable-owner' const CONNECTION = 'conn-1' const WORKTREE = 'repo-1::/tmp/pane-absence' @@ -62,6 +61,7 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } { session = next }, flushOrThrow: () => {}, + retirePtyBinding: async () => true, getRepos: () => [ { id: 'repo-1', @@ -137,16 +137,12 @@ describe('a stable pane whose reattach was refused', () => { }) it('still certifies the death the relay proved with a pid probe', async () => { - const { runtime, store, spawn } = await adoptAfterAttachRefusal( + const { runtime, spawn } = await adoptAfterAttachRefusal( new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty2:epoch-a:1`) ) expect(spawn).toHaveBeenCalledTimes(2) expect(runtime.getPtyLivenessVerdict(PTY_ID)).toEqual({ status: 'exited' }) - // If nothing ever retired, a proven-dead pane would reattach to a corpse on every adoption. - expect( - resolvePersistedStablePaneOwner(store, makePaneKey(TAB, LEAF), WORKTREE, CONNECTION) - ).toBeNull() }) it('certifies an absence reported by the process registry that owns the PTY', async () => { diff --git a/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts new file mode 100644 index 000000000000..cf647ca7d175 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts @@ -0,0 +1,161 @@ +// Waking a slept split after a relaunch reattaches each pane to a process the sleep stopped. The +// host answers absent and the pane spawns fresh, so main's copy must keep the pane, its binding and +// the tab row until the fresh spawn's bind swaps them: dropping the pane lets that spawn re-mint +// the tab and reorder the split, a pane shown unbound meanwhile reads as gone to the agent resume, +// and a released row goes to whichever pane binds first. +import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' +import type { Store } from '../../../persistence' +import { clearReplacedPaneBinding } from '../../../persistence/loading-store/replaced-pane-binding' +import { applyPtyBinding } from '../../../persistence/loading-store/pty-binding-session-update' +import type { IPtyProvider } from '../../../providers/types' +import { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { spawnForStablePane } from './stable-owner' + +const WORKTREE = 'repo-1::/tmp/pane-respawn' +const TAB = 'tab-1' +const SHELL_LEAF = '1b3f2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e' +const AGENT_LEAF = '2c4d3e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f' +const SHELL_PTY = `${WORKTREE}@@aaaa0001` +const AGENT_PTY = `${WORKTREE}@@aaaa0002` +const SPLIT: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: SHELL_LEAF }, + second: { type: 'leaf', leafId: AGENT_LEAF } +} + +const ROW: TerminalTab = { + id: TAB, + worktreeId: WORKTREE, + ptyId: SHELL_PTY, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 +} + +function sleptSplit(): { store: Store; read: () => WorkspaceSessionState } { + let session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE]: [ROW] }, + terminalLayoutsByTabId: { + [TAB]: { + root: SPLIT, + activeLeafId: AGENT_LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [SHELL_LEAF]: SHELL_PTY, [AGENT_LEAF]: AGENT_PTY } + } + } + } + return { + read: () => session, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the persistence and catalog reads stable-pane adoption and exit bookkeeping use. + store: withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + retirePtyBinding: async (...[binding]: Parameters) => { + session = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) + return true + }, + flushOrThrow: () => {}, + getRepos: () => [ + { + id: 'repo-1', + path: '/tmp/pane-respawn', + displayName: 'r', + badgeColor: '#000', + addedAt: 0 + } + ], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => {}, + removeWorktreeMeta: () => {}, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [] + }) as unknown as Store + } +} + +/** The waking window names each pane's slept PTY, as its graph does before the reattach. */ +function wakingRuntime(store: Store): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(store as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + hasPty: () => null, + listProcesses: async () => [], + getForegroundProcess: async () => null + } as never) + runtime.attachWindow(1) + runtime.registerPty(SHELL_PTY, WORKTREE, null, { tabId: TAB, leafId: SHELL_LEAF }) + runtime.registerPty(AGENT_PTY, WORKTREE, null, { tabId: TAB, leafId: AGENT_LEAF }) + return runtime +} + +async function respawnOverAbsentProcess( + runtime: OrcaRuntimeService, + store: Store, + leafId: string, + ptyId: string +): Promise { + const spawn = vi + .fn() + .mockRejectedValueOnce(new SessionNotFoundError(ptyId)) + .mockResolvedValueOnce({ id: `${ptyId}-fresh`, isReattach: false }) + const result = await spawnForStablePane({ + runtime, + store, + provider: { spawn } as unknown as IPtyProvider, + spawnOptions: { cols: 80, rows: 24 }, + owner: { tabId: TAB, leafId, ptyId, hasPersistedBinding: true }, + worktreeId: WORKTREE, + resolveOwner: () => null + }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(result.owner).toBeNull() +} + +describe('a stable pane respawned over an absent process', () => { + it('keeps the panes, their bindings and the row for the fresh binds to swap', async () => { + const { store, read } = sleptSplit() + const before = structuredClone(read()) + const runtime = wakingRuntime(store) + + await respawnOverAbsentProcess(runtime, store, SHELL_LEAF, SHELL_PTY) + await respawnOverAbsentProcess(runtime, store, AGENT_LEAF, AGENT_PTY) + + expect(read().terminalLayoutsByTabId).toEqual(before.terminalLayoutsByTabId) + expect(read().tabsByWorktree[WORKTREE]).toEqual([ROW]) + }) + + it.each([ + ['shell', SHELL_LEAF, AGENT_LEAF], + ['agent', AGENT_LEAF, SHELL_LEAF] + ])( + 'moves the row to the fresh PTY of the pane it named, binding the %s first', + async (_, ...order) => { + const { store, read } = sleptSplit() + const runtime = wakingRuntime(store) + await respawnOverAbsentProcess(runtime, store, SHELL_LEAF, SHELL_PTY) + await respawnOverAbsentProcess(runtime, store, AGENT_LEAF, AGENT_PTY) + const session = structuredClone(read()) + const fresh = { [SHELL_LEAF]: `${SHELL_PTY}-fresh`, [AGENT_LEAF]: `${AGENT_PTY}-fresh` } + for (const leafId of order) { + const binding = { worktreeId: WORKTREE, tabId: TAB, leafId, ptyId: fresh[leafId] } + applyPtyBinding(binding, session, WORKTREE, makePaneKey(TAB, leafId)) + } + + expect(session.tabsByWorktree[WORKTREE]).toEqual([{ ...ROW, ptyId: fresh[SHELL_LEAF] }]) + } + ) +}) diff --git a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts index ce3c578cfa03..bb3bf673f9b0 100644 --- a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts @@ -7,6 +7,7 @@ import { SshPtyAbsentFromRelayError } from '../../../providers/ssh-pty-errors' import type { Store } from '../../../persistence' +import { clearReplacedPaneBinding } from '../../../persistence/loading-store/replaced-pane-binding' import type { IPtyProvider } from '../../../providers/types' import { spawnForStablePane, type StablePaneOwner } from './stable-owner' @@ -77,6 +78,10 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, + retirePtyBinding: async (...[binding]: Parameters) => { + session = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) + return true + }, flushOrThrow: () => {} }) as unknown as Store } @@ -152,47 +157,27 @@ describe('stable pane adoption after the relay reports the PTY absent', () => { expect(spawn).toHaveBeenCalledTimes(1) }) - // Why a real store: without one the `args.worktreeId` guard short-circuits and the retirement — - // which can delete the parent tab and its layout — never runs. "Reconnect lost every tab" is the - // regression this subsystem was reverted for twice, so the composition needs its own coverage. + // Why a real store: "Reconnect lost every tab" is the regression this subsystem was reverted for + // twice. The fresh spawn takes the pane, so the absence leaves the tab, its layout, the bindings + // and the row for that spawn's bind to swap. describe('with persistence actually reached', () => { - it('retires only the absent leaf and leaves the tab and its sibling bound', async () => { - const { store, read } = sessionStore([LEAF, SIBLING_LEAF]) - const { run, spawn } = spawnAfterAttachRejection( - new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), - { store, worktreeId: WORKTREE } - ) - - const result = await run() - - expect(spawn).toHaveBeenCalledTimes(2) - expect(result.owner).toBeNull() - const session = read() - expect(session.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['tab-1']) - const layout = session.terminalLayoutsByTabId['tab-1'] - expect(layout).toBeDefined() - expect(layout?.ptyIdsByLeafId?.[SIBLING_LEAF]).toBe('ssh:conn-1@@pty-2') - expect(layout?.ptyIdsByLeafId?.[LEAF]).toBeUndefined() - }) - - // Pins current behaviour rather than blessing it: retiring the LAST leaf drops the tab from - // persistence, and the fallback returns owner=null so main does not re-persist a binding — tab - // survival then rests entirely on the renderer. If that ever regresses, this is the tripwire. - it('drops the tab when the absent leaf was the only one, leaving re-persistence to the renderer', async () => { - const { store, read } = sessionStore([LEAF]) - const { run, spawn } = spawnAfterAttachRejection( - new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), - { store, worktreeId: WORKTREE } - ) + it.each([[[LEAF, SIBLING_LEAF]], [[LEAF]]])( + 'keeps the tab, its panes and their bindings (leaves %j)', + async (leaves) => { + const { store, read } = sessionStore(leaves) + const before = structuredClone(read()) + const { run, spawn } = spawnAfterAttachRejection( + new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), + { store, worktreeId: WORKTREE } + ) - const result = await run() + const result = await run() - expect(spawn).toHaveBeenCalledTimes(2) - expect(result.owner).toBeNull() - const session = read() - expect(session.tabsByWorktree[WORKTREE]).toEqual([]) - expect(session.terminalLayoutsByTabId['tab-1']).toBeUndefined() - }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(result.owner).toBeNull() + expect(read()).toEqual(before) + } + ) it('leaves persistence untouched when the failure is not positive absence', async () => { const { store, read } = sessionStore([LEAF, SIBLING_LEAF]) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index e6e301aa8afa..2f9b6f65ca5b 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -143,7 +143,6 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { tabId, leafId, ptyId: ctx.result.id, - hostAdmittedMembership: true, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), ...(expectedSourceBinding ? { expectedSourceBinding } : {}), diff --git a/src/main/ipc/remote-workspace-export-test-harness.ts b/src/main/ipc/remote-workspace-export-test-harness.ts new file mode 100644 index 000000000000..fed2d0af4bd1 --- /dev/null +++ b/src/main/ipc/remote-workspace-export-test-harness.ts @@ -0,0 +1,127 @@ +import { ipcMain } from 'electron' +import { vi } from 'vitest' +import type { Store } from '../persistence' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspaceExportAuthority, + RemoteWorkspacePeerImport, + RemoteWorkspacePushStatusEvent +} from '../../shared/remote-workspace-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { registerRemoteWorkspaceHandlers } from './remote-workspace' + +/** Past main's export coalescing window and its starvation cap; tests run it on fake timers. */ +export async function passExportWindow(): Promise { + await vi.advanceTimersByTimeAsync(5_000) +} + +export const EMPTY_WORKSPACE_SESSION: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} +} + +export type RemoteWorkspaceExportDriver = { + handlers: Map unknown> + /** Every push-status report main sent, in order. */ + pushes: RemoteWorkspacePushStatusEvent[] + /** A window's pull of `targetId` with nothing to import. */ + agree: ( + targetId: string, + authority: RemoteWorkspaceExportAuthority, + outcome?: RemoteWorkspacePeerImport['outcome'] + ) => void + importPeer: (pull: RemoteWorkspacePeerImport) => void + /** A session write by anything other than an import, patched onto the one partition. */ + write: (patch: Partial) => void + readSession: () => WorkspaceSessionState + /** Resolves once `count` more push reports have arrived. */ + nextPushes: (count?: number) => Promise +} + +/** + * Registers the remote-workspace handlers against a one-partition store fake whose session writes + * reach main's export trigger, and records the push reports main sends the window. + */ +export function createRemoteWorkspaceExportDriver( + store: Partial, + /** The window's `remoteWorkspace:changed` listener. */ + onChanged: (event: RemoteWorkspaceChangedEvent) => void = () => {} +): RemoteWorkspaceExportDriver { + const handlers = new Map unknown>() + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) + }) + let session = EMPTY_WORKSPACE_SESSION + const writeListeners = new Set<() => void>() + const pushes: RemoteWorkspacePushStatusEvent[] = [] + const waiters = new Set<() => void>() + const window = { + isDestroyed: () => false, + webContents: { + send: ( + channel: string, + event: RemoteWorkspacePushStatusEvent | RemoteWorkspaceChangedEvent + ) => { + if (channel === 'remoteWorkspace:changed' && 'snapshot' in event) { + onChanged(event) + } else if (channel === 'remoteWorkspace:pushStatus' && 'authority' in event) { + pushes.push(event) + for (const waiter of waiters) { + waiter() + } + } + } + } + } + const fakeStore = { + getSshTarget: () => undefined, + ...store, + getWorkspaceSession: (hostId?: string | null) => + !hostId || hostId === 'local' ? session : EMPTY_WORKSPACE_SESSION, + patchWorkspaceSession: (patch: Partial) => { + session = { ...session, ...patch } + for (const listener of writeListeners) { + listener() + } + }, + onWorkspaceSessionWritten: (listener: () => void) => { + writeListeners.add(listener) + return () => writeListeners.delete(listener) + } + } + registerRemoteWorkspaceHandlers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every Store member the remote-workspace handlers read. + fakeStore as unknown as Store, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the window fake exposes only the webContents.send the handlers call. + () => window as never, + { readMachineName: () => 'Build server' } + ) + const importPeer = (pull: RemoteWorkspacePeerImport): void => { + void handlers.get('remoteWorkspace:importPeerTopology')?.(null, pull) + } + return { + handlers, + pushes, + agree: (targetId, authority, outcome = 'synced') => + importPeer({ targetId, ...authority, outcome, session: {} }), + importPeer, + write: (patch) => fakeStore.patchWorkspaceSession(patch), + readSession: () => session, + nextPushes: (count = 1) => { + const target = pushes.length + count + return new Promise((resolve) => { + const check = (): void => { + if (pushes.length >= target) { + waiters.delete(check) + resolve(pushes.slice(target - count, target)) + } + } + waiters.add(check) + check() + }) + } + } +} diff --git a/src/main/ipc/remote-workspace-export.ts b/src/main/ipc/remote-workspace-export.ts new file mode 100644 index 000000000000..8b7b90b8c164 --- /dev/null +++ b/src/main/ipc/remote-workspace-export.ts @@ -0,0 +1,194 @@ +import type { Store } from '../persistence' +import type { SshTarget } from '../../shared/ssh-types' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { + RemoteWorkspaceExportAuthority, + RemoteWorkspaceObservedPatchResult, + RemoteWorkspacePeerImport, + RemoteWorkspacePushStatusEvent, + RemoteWorkspaceSession +} from '../../shared/remote-workspace-types' +import { createRepoRowExecutionHostLookup } from '../../shared/worktree-execution-host-resolution' +import { getActiveMultiplexer, getSshConnectionStore } from './ssh' +import { + createWorktreeOwnerResolver, + createWorktreeTargetResolver, + exportSessionForTarget, + persistedSessionForTarget, + type WorktreeOwnerResolver +} from './remote-workspace-target-session-export' +import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue' +import { createKeyedTrailingEdgeCoalescer } from '../runtime/keyed-trailing-edge-coalescer' +import { getRemoteSnapshot, patchRemoteWorkspaceSession } from './remote-workspace-relay-sync' +import { + cachedRemoteWorkspaceSnapshotAuthorizesRevision, + getCachedRemoteWorkspaceSnapshot +} from './remote-workspace-snapshot-cache' +import { remoteWorkspaceSessionsMatch } from './remote-workspace-snapshot-normalization' + +/** Outlasts the window's 150ms save debounce after a write main made for it. */ +const EXPORT_QUIET_MS = 500 +const EXPORT_MAX_WAIT_MS = 2_000 +/** One coalesced export covers every agreed host, so ownership resolves once for all of them. */ +const EXPORT_KEY = 'all-hosts' + +/** + * What this desktop and a host agree the host holds; null when the host's copy is unknown. It holds + * only over the connection it was reached on: after a reconnect the host may have moved on, and + * this desktop's own writes in the gap (exits retired by a relay's death) are not the host's news. + */ +type HostAgreement = { + authority: RemoteWorkspaceExportAuthority + session: RemoteWorkspaceSession | null + connection: SshChannelMultiplexer | undefined +} + +export type RemoteWorkspaceExports = { + /** Records a window's pull; the import's own writes must already be in the store. */ + recordPull: (pull: Omit) => void + /** Run after every session write: exports to each agreed host whose share of it changed. */ + exportChanged: () => void +} + +/** + * Exports this desktop's session to its SSH hosts. An export goes only when what this desktop holds + * for a host differs from what the two last agreed on, so an import (which sets the agreement) and + * the mirror apply it causes never export, while a local change exports once. + */ +export function createRemoteWorkspaceExports( + store: Store, + sendPushStatus: (event: RemoteWorkspacePushStatusEvent) => void +): RemoteWorkspaceExports { + const agreements = new Map() + + const readWorktreeOwners = (): WorktreeOwnerResolver => + createWorktreeOwnerResolver(createRepoRowExecutionHostLookup(store.getRepos())) + const sessionForTarget = ( + resolveWorktreeOwner: WorktreeOwnerResolver, + targetId: string + ): RemoteWorkspaceSession => + exportSessionForTarget( + createWorktreeTargetResolver(resolveWorktreeOwner), + targetId, + persistedSessionForTarget(store, targetId, resolveWorktreeOwner) + ) + + const exportTo = async ( + target: SshTarget, + session: RemoteWorkspaceSession, + agreement: HostAgreement + ): Promise => { + if ( + agreements.get(target.id) !== agreement || + agreement.connection !== getActiveMultiplexer(target.id) || + (agreement.session && remoteWorkspaceSessionsMatch(session, agreement.session)) + ) { + return + } + let result: RemoteWorkspaceObservedPatchResult | null + try { + result = await patchUnderAgreement(target, session, agreement.authority) + } catch (error) { + if (agreements.get(target.id) !== agreement) { + return + } + sendPushStatus({ + targetId: target.id, + authority: agreement.authority, + result: null, + error: error instanceof Error ? error.message : 'Workspace upload failed' + }) + return + } + // A pull that landed meanwhile is newer than this export's outcome. + if (agreements.get(target.id) !== agreement) { + return + } + if (result?.ok) { + const { revision, hostObservationToken } = result.snapshot + agreements.set(target.id, { + ...agreement, + authority: { revision, hostObservationToken }, + session + }) + } else if (result?.reason === 'stale-revision') { + // The host moved on; the window's next pull agrees again. + agreements.delete(target.id) + } + sendPushStatus({ targetId: target.id, authority: agreement.authority, result }) + } + + const exportAll = async (): Promise => { + const targets = + getSshConnectionStore() + ?.listTargets() + .filter((target) => agreements.has(target.id) && getActiveMultiplexer(target.id)) ?? [] + if (targets.length === 0) { + return + } + // One catalog read and one ownership resolution per worktree, shared by every target. + const resolveWorktreeOwner = readWorktreeOwners() + await Promise.all( + targets.map((target) => { + const session = sessionForTarget(resolveWorktreeOwner, target.id) + // Each target has its own revision stream; one slow relay must not block another. + return queueRemoteWorkspacePatch(target.id, async () => { + const agreement = agreements.get(target.id) + return agreement ? exportTo(target, session, agreement) : undefined + }) + }) + ) + } + + // A write burst exports once: a split's binding write and the window's save after it are one patch. + const exportBurst = createKeyedTrailingEdgeCoalescer(() => void exportAll(), { + flushMs: EXPORT_QUIET_MS, + maxWaitMs: EXPORT_MAX_WAIT_MS + }) + const exportChanged = (): void => exportBurst.schedule(EXPORT_KEY) + + const recordPull: RemoteWorkspaceExports['recordPull'] = ({ + targetId, + revision, + hostObservationToken, + outcome + }) => { + if (outcome === 'conflict') { + agreements.delete(targetId) + return + } + // An import agrees on what this desktop now holds, so it never exports back. When the window + // kept local state the host lacks, agree on the host's own copy instead, so that state exports + // once, as the window's save-back did. + const session = + outcome === 'kept-local' + ? (getCachedRemoteWorkspaceSnapshot(targetId)?.session ?? null) + : sessionForTarget(readWorktreeOwners(), targetId) + agreements.set(targetId, { + authority: { revision, hostObservationToken }, + session, + connection: getActiveMultiplexer(targetId) + }) + exportChanged() + } + + return { recordPull, exportChanged } +} + +/** Patches only while the host is still at the revision and observation this desktop agreed on. */ +async function patchUnderAgreement( + target: SshTarget, + session: RemoteWorkspaceSession, + authority: RemoteWorkspaceExportAuthority +): Promise { + const current = getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) + if ( + !current || + current.hostObservationToken !== authority.hostObservationToken || + !cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, authority.revision) + ) { + const latest = getCachedRemoteWorkspaceSnapshot(target.id) ?? current + return latest ? { ok: false, reason: 'stale-revision', snapshot: latest } : null + } + return patchRemoteWorkspaceSession(target, session) +} diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index d82ba5e845a5..a9b54b29d3fb 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -36,9 +36,12 @@ vi.mock('./remote-workspace-events', () => ({ import { _resetRemoteWorkspaceCachesForTests, - handleRemoteWorkspaceNotification, - registerRemoteWorkspaceHandlers + handleRemoteWorkspaceNotification } from './remote-workspace' +import { + createRemoteWorkspaceExportDriver, + type RemoteWorkspaceExportDriver +} from './remote-workspace-export-test-harness' import { CLIENT_ID } from './remote-workspace-client-identity' import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue' import { @@ -73,16 +76,20 @@ function patchSession(params: Record): RemoteWorkspaceSession { return (params.patch as { session: RemoteWorkspaceSession }).session } -describe('remoteWorkspace:setForConnectedTargets patch queue', () => { - const handlers = new Map unknown>() +describe('main export patch queue', () => { + let driver: RemoteWorkspaceExportDriver const muxByTargetId = new Map }>() const getRepoMock = vi.fn() // Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one. const KNOWN_REPO_IDS = ['repo-target-1', 'repo-target-2', 'repo-reset', 'repo-newer'] - const store = { + const store: Partial = { getRepo: getRepoMock, - getRepos: () => KNOWN_REPO_IDS.map((repoId) => getRepoMock(repoId)).filter(Boolean) - } as unknown as Store + getRepos: () => + KNOWN_REPO_IDS.flatMap((repoId) => { + const repo = getRepoMock(repoId) + return repo ? [repo] : [] + }) + } const target: SshTarget = { id: 'target-1', @@ -94,12 +101,8 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { beforeEach(() => { _resetRemoteWorkspaceCachesForTests() - handlers.clear() muxByTargetId.clear() vi.mocked(ipcMain.handle).mockReset() - vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { - handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) - }) vi.mocked(ipcMain.removeHandler).mockReset() getSshConnectionStoreMock.mockReset() getSshConnectionStoreMock.mockReturnValue({ @@ -123,22 +126,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { getActiveMultiplexerMock.mockImplementation((targetId: string) => muxByTargetId.get(targetId)) registerRemoteWorkspaceNotificationHandlerMock.mockClear() - registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) + driver = createRemoteWorkspaceExportDriver(store) }) - async function callSetForConnectedTargets(args: { - session: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - }): Promise { - const handler = handlers.get('remoteWorkspace:setForConnectedTargets') - if (!handler) { - throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') - } - return handler(null, args) - } - function observeSnapshot(targetId: string, value: RemoteWorkspaceSnapshot): string { return rememberRemoteWorkspaceSnapshot(targetId, value).hostObservationToken } @@ -151,7 +141,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { return cached.hostObservationToken } - it('serializes overlapping writes for the same target so they use fresh base revisions', async () => { + it('serializes overlapping exports for the same target so they use fresh base revisions', async () => { let currentRevision = 7 let releaseFirstPatch!: () => void const firstPatchCanFinish = new Promise((resolve) => { @@ -204,43 +194,32 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - const first = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + const pushes = driver.nextPushes(2) + driver.write(sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a')) await vi.waitFor(() => expect(patchBaseRevisions).toEqual([7])) - const second = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace-b', 'tab-b'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.write(sessionWithTab('repo-target-1::/remote/workspace-b', 'tab-b')) await new Promise((resolve) => setTimeout(resolve, 0)) expect(patchBaseRevisions).toEqual([7]) releaseFirstPatch() - await expect(Promise.all([first, second])).resolves.toMatchObject([ - [ - { - targetId: 'target-1', - result: { - ok: true, - snapshot: { revision: 8, hostObservationToken: observationToken } - } + await expect(pushes).resolves.toMatchObject([ + { + targetId: 'target-1', + result: { + ok: true, + snapshot: { revision: 8, hostObservationToken: observationToken } } - ], - [ - { - targetId: 'target-1', - result: { - ok: true, - snapshot: { revision: 9, hostObservationToken: observationToken } - } + }, + { + targetId: 'target-1', + authority: { revision: 8 }, + result: { + ok: true, + snapshot: { revision: 9, hostObservationToken: observationToken } } - ] + } ]) expect(patchBaseRevisions).toEqual([7, 8]) }) @@ -272,16 +251,13 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { snapshot: remoteSnapshot, sourceClientId: 'other-client' }) - const result = await callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.write(sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab')) + const result = await driver.nextPushes() expect(result).toMatchObject([ { @@ -296,7 +272,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { expect(request).not.toHaveBeenCalled() }) - it('rejects a renderer upload when a host snapshot arrives while it is queued', async () => { + it('drops a queued export when a host snapshot arrives while the first one is in flight', async () => { const remoteSnapshot = snapshot( { activeWorktreePath: '/other-device', @@ -344,21 +320,12 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - const first = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/first', 'first-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-target-1::/remote/first', 'first-local-tab')) await vi.waitFor(() => expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1) ) - const queued = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/queued', 'queued-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.write(sessionWithTab('repo-target-1::/remote/queued', 'queued-local-tab')) await new Promise((resolve) => setTimeout(resolve, 0)) handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { @@ -367,14 +334,16 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { }) releasePatch() - await expect(Promise.all([first, queued])).resolves.toMatchObject([ - [{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }], - [{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }] + await expect(driver.nextPushes()).resolves.toMatchObject([ + { targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } } ]) + // The stale reply ended the agreement; the queued export waits for the window's next pull. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(driver.pushes).toHaveLength(1) expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1) }) - it('rejects a queued upload after a same-revision host observation replaces its lineage', async () => { + it('rejects a queued export after a same-revision host observation replaces its lineage', async () => { const baseline = snapshot( { activeWorktreePath: '/baseline', @@ -422,12 +391,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { }) await blockerDidStart - const queued = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + const queued = driver.nextPushes() + driver.write(sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab')) await new Promise((resolve) => setTimeout(resolve, 0)) handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { snapshot: replacement, @@ -469,14 +435,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { }) muxByTargetId.set('target-1', { request }) - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - ).resolves.toMatchObject([ + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab')) + await expect(driver.nextPushes()).resolves.toMatchObject([ { targetId: 'target-1', result: { ok: false, reason: 'stale-revision', snapshot: { revision: 7 } } @@ -485,7 +446,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { expect(request.mock.calls.map(([method]) => method)).toEqual(['workspace.get']) }) - it('patches independent hydrated targets concurrently', async () => { + it('patches independent agreed targets concurrently', async () => { const secondTarget: SshTarget = { id: 'target-2', label: 'Target 2', @@ -557,33 +518,28 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { const firstObservationToken = observeSnapshot('target-1', previousSnapshot) const secondObservationToken = observeSnapshot('target-2', previousSnapshot) - const resultPromise = callSetForConnectedTargets({ - session: { - ...sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), - tabsByWorktree: { - 'repo-target-1::/remote/workspace-a': [ - { - id: 'tab-a', - type: 'terminal', - title: 'Shell A', - worktreeId: 'repo-target-1::/remote/workspace-a' - } as never - ], - 'repo-target-2::/remote/workspace-b': [ - { - id: 'tab-b', - type: 'terminal', - title: 'Shell B', - worktreeId: 'repo-target-2::/remote/workspace-b' - } as never - ] - } - }, - hydratedTargetIds: ['target-1', 'target-2'], - expectedRevisionsByTargetId: { 'target-1': 7, 'target-2': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': firstObservationToken, - 'target-2': secondObservationToken + driver.agree('target-1', { revision: 7, hostObservationToken: firstObservationToken }) + driver.agree('target-2', { revision: 7, hostObservationToken: secondObservationToken }) + const resultPromise = driver.nextPushes(2) + driver.write({ + ...sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), + tabsByWorktree: { + 'repo-target-1::/remote/workspace-a': [ + { + id: 'tab-a', + type: 'terminal', + title: 'Shell A', + worktreeId: 'repo-target-1::/remote/workspace-a' + } as never + ], + 'repo-target-2::/remote/workspace-b': [ + { + id: 'tab-b', + type: 'terminal', + title: 'Shell B', + worktreeId: 'repo-target-2::/remote/workspace-b' + } as never + ] } }) @@ -595,9 +551,10 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) releaseFirstPatch() + // The fast target reports first: neither waited on the other. await expect(resultPromise).resolves.toMatchObject([ - { targetId: 'target-1', result: { ok: true } }, - { targetId: 'target-2', result: { ok: true } } + { targetId: 'target-2', result: { ok: true } }, + { targetId: 'target-1', result: { ok: true } } ]) }) @@ -678,14 +635,11 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-reset::/remote/workspace', 'tab-reset'), - hydratedTargetIds: ['target-reset'], - expectedRevisionsByTargetId: { 'target-reset': 7 }, - expectedHostObservationTokensByTargetId: { 'target-reset': observationToken } - }) - ).resolves.toMatchObject([{ targetId: 'target-reset', result: { ok: true } }]) + driver.agree('target-reset', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-reset::/remote/workspace', 'tab-reset')) + await expect(driver.nextPushes()).resolves.toMatchObject([ + { targetId: 'target-reset', result: { ok: true } } + ]) expect(patchBaseRevisions).toEqual([7, 0]) }) @@ -760,14 +714,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-newer::/remote/workspace', 'tab-local'), - hydratedTargetIds: ['target-newer'], - expectedRevisionsByTargetId: { 'target-newer': 7 }, - expectedHostObservationTokensByTargetId: { 'target-newer': observationToken } - }) - ).resolves.toMatchObject([ + driver.agree('target-newer', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-newer::/remote/workspace', 'tab-local')) + await expect(driver.nextPushes()).resolves.toMatchObject([ { targetId: 'target-newer', result: { ok: false, reason: 'stale-revision' } } ]) expect(patchBaseRevisions).toEqual([7]) diff --git a/src/main/ipc/remote-workspace-snapshot-normalization.ts b/src/main/ipc/remote-workspace-snapshot-normalization.ts index 749d83cc0663..e35e2c8b6272 100644 --- a/src/main/ipc/remote-workspace-snapshot-normalization.ts +++ b/src/main/ipc/remote-workspace-snapshot-normalization.ts @@ -96,11 +96,13 @@ export function remoteWorkspaceSessionMatchesSnapshot( snapshot: RemoteWorkspaceSnapshot | undefined, session: RemoteWorkspaceSession ): boolean { - if (!snapshot) { - return false - } - return isDeepStrictEqual( - normalizeRemoteSession(snapshot.session), - normalizeRemoteSession(session) - ) + return snapshot !== undefined && remoteWorkspaceSessionsMatch(snapshot.session, session) +} + +/** Equal once empty optional projection fields and absent ones read the same. */ +export function remoteWorkspaceSessionsMatch( + a: RemoteWorkspaceSession, + b: RemoteWorkspaceSession +): boolean { + return isDeepStrictEqual(normalizeRemoteSession(a), normalizeRemoteSession(b)) } diff --git a/src/main/ipc/remote-workspace-stale-resync.test.ts b/src/main/ipc/remote-workspace-stale-resync.test.ts index b87a9a9893f3..d27bf042cfff 100644 --- a/src/main/ipc/remote-workspace-stale-resync.test.ts +++ b/src/main/ipc/remote-workspace-stale-resync.test.ts @@ -65,7 +65,12 @@ function snapshot(revision: number, tabId: string): RemoteWorkspaceSnapshot { describe('workspace.stale resync', () => { const sent: RemoteWorkspaceChangedEvent[] = [] const request = vi.fn() - const store = { getRepo: vi.fn(), getWorkspaceSession: vi.fn() } as unknown as Store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resync reads no store member; registration only subscribes to session writes. + const store = { + getRepo: vi.fn(), + getWorkspaceSession: vi.fn(), + onWorkspaceSessionWritten: vi.fn(() => () => {}) + } as unknown as Store beforeEach(() => { sent.length = 0 diff --git a/src/main/ipc/remote-workspace-two-desktops.test.ts b/src/main/ipc/remote-workspace-two-desktops.test.ts new file mode 100644 index 000000000000..42db5a6e0db7 --- /dev/null +++ b/src/main/ipc/remote-workspace-two-desktops.test.ts @@ -0,0 +1,365 @@ +/** + * Blocking (plan §4.1a "SSH no export loop"): two desktops share one relay. An import, and the + * window's save-back of what it applied, export nothing; a local split on either desktop exports + * exactly once; a conflicted import keeps that desktop out of exports. + * + * Each desktop is its own module graph (own snapshot cache, patch queue and CLIENT_ID), as two + * processes would be. The relay is an in-memory workspace with revisions. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspaceSession, + RemoteWorkspaceSnapshot +} from '../../shared/remote-workspace-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { importRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' +import type * as RemoteWorkspaceModule from './remote-workspace' +import type * as ExportDriverModule from './remote-workspace-export-test-harness' + +type Relay = { + revision: number + session: RemoteWorkspaceSession + patches: { clientId: string; baseRevision: number }[] + listeners: ((snapshot: RemoteWorkspaceSnapshot, clientId: string) => void)[] +} + +const { relay } = vi.hoisted(() => { + const relay: Relay = { + revision: 0, + session: { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }, + patches: [], + listeners: [] + } + return { relay } +}) + +const TARGET: SshTarget = { + id: 'target-1', + label: 'Build box', + host: 'build.example.com', + port: 22, + username: 'dev' +} +const WORKTREE_PATH = '/srv/app' +const WORKTREE_ID = `repo-1::${WORKTREE_PATH}` + +type RelayPatchParams = { + baseRevision: number + clientId: string + patch: { session: RemoteWorkspaceSession } +} + +function relaySnapshot(): RemoteWorkspaceSnapshot { + return { + namespace: 'workspace', + revision: relay.revision, + updatedAt: relay.revision, + schemaVersion: 1, + session: structuredClone(relay.session) + } +} + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() } +})) +vi.mock('./remote-workspace-events', () => ({ + registerRemoteWorkspaceNotificationHandler: () => () => {} +})) +vi.mock('./ssh', () => { + // One connection for the whole suite: an agreement holds only over the connection it was made on. + const connection = { + request: async (method: string, { baseRevision, clientId, patch }: RelayPatchParams) => { + if (method === 'workspace.get') { + return relaySnapshot() + } + relay.patches.push({ clientId, baseRevision }) + if (baseRevision !== relay.revision) { + return { + ok: false, + reason: 'stale-revision', + snapshot: relaySnapshot() + } + } + relay.revision += 1 + relay.session = structuredClone(patch.session) + const snapshot = relaySnapshot() + for (const listener of relay.listeners) { + listener(snapshot, clientId) + } + return { ok: true, snapshot } + } + } + return { + getSshConnectionStore: () => ({ + listTargets: () => [TARGET], + getTarget: () => TARGET + }), + getActiveMultiplexer: () => connection + } +}) + +type Desktop = { + driver: ExportDriverModule.RemoteWorkspaceExportDriver + clientId: string + /** Set to make this desktop's next imports conflicted (unplaceable host tabs). */ + conflicted: boolean + /** Tabs the window created during a download and has not saved yet; its merge keeps them. */ + unsavedTabs: WorkspaceSessionState['tabsByWorktree'][string] + imports: number +} + +function tab(id: string): WorkspaceSessionState['tabsByWorktree'][string][number] { + return { + id, + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function withSplit( + session: WorkspaceSessionState, + tabId: string, + direction: 'vertical' | 'horizontal' = 'vertical' +): WorkspaceSessionState { + return { + ...session, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [tabId]: { + root: { + type: 'split', + direction, + first: { type: 'leaf', leafId: `${tabId}-a` }, + second: { type: 'leaf', leafId: `${tabId}-b` } + }, + activeLeafId: `${tabId}-a`, + expandedLeafId: null + } + } + } +} + +async function bootDesktop(): Promise { + vi.resetModules() + const remoteWorkspace: typeof RemoteWorkspaceModule = await import('./remote-workspace') + const { CLIENT_ID } = await import('./remote-workspace-client-identity') + const remoteWorkspaceCache = await import('./remote-workspace-snapshot-cache') + const { createRemoteWorkspaceExportDriver }: typeof ExportDriverModule = + await import('./remote-workspace-export-test-harness') + remoteWorkspace._resetRemoteWorkspaceCachesForTests() + const repo: Repo = { + id: 'repo-1', + path: '/srv', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + connectionId: TARGET.id + } + const desktop: Desktop = { + clientId: CLIENT_ID, + conflicted: false, + unsavedTabs: [], + imports: 0, + // The window's importer: the host snapshot's projection plus the unsaved tabs the merge keeps, + // committed through main, then saved back once the window has applied it. + driver: createRemoteWorkspaceExportDriver( + { getRepos: () => [repo] }, + (event: RemoteWorkspaceChangedEvent) => { + if (event.sourceClientId === CLIENT_ID) { + return + } + const imported = importRemoteWorkspaceSession(event.snapshot.session, { + resolveWorktreeId: (path) => (path === WORKTREE_PATH ? WORKTREE_ID : null), + executionHostId: `ssh:${TARGET.id}` + }) + const hostTabs = imported.tabsByWorktree[WORKTREE_ID] ?? [] + const keptTabs = desktop.unsavedTabs.filter( + (local) => !hostTabs.some((host) => host.id === local.id) + ) + desktop.unsavedTabs = [] + const patch = { + tabsByWorktree: { ...imported.tabsByWorktree, [WORKTREE_ID]: [...hostTabs, ...keptTabs] }, + terminalLayoutsByTabId: imported.terminalLayoutsByTabId + } + desktop.imports += 1 + desktop.driver.importPeer({ + targetId: TARGET.id, + revision: event.snapshot.revision, + hostObservationToken: event.snapshot.hostObservationToken, + outcome: desktop.conflicted ? 'conflict' : keptTabs.length > 0 ? 'kept-local' : 'synced', + session: patch + }) + desktop.driver.write(patch) + } + ) + } + relay.listeners.push((snapshot, sourceClientId) => + remoteWorkspace.handleRemoteWorkspaceNotification(TARGET.id, 'workspace.changed', { + snapshot, + sourceClientId + }) + ) + // Connect: the window pulls and agrees with the host. + await desktop.driver.handlers.get('remoteWorkspace:get')?.(null, { targetId: TARGET.id }) + const observed = remoteWorkspaceCache.getCachedRemoteWorkspaceSnapshot(TARGET.id) + if (!observed) { + throw new Error('connect did not observe the host') + } + desktop.driver.agree(TARGET.id, observed) + return desktop +} + +/** Lets every export, the peer's import of it, and any export that causes run out. */ +async function settle(): Promise { + for (let turn = 0; turn < 5; turn += 1) { + await vi.advanceTimersByTimeAsync(5_000) + } +} + +describe('two desktops, one relay', () => { + let a: Desktop + let b: Desktop + + afterEach(() => { + vi.useRealTimers() + }) + + beforeEach(async () => { + vi.useFakeTimers() + relay.revision = 3 + relay.session = { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: { [WORKTREE_PATH]: [] }, + terminalLayoutsByTabId: {} + } + relay.patches = [] + relay.listeners = [] + a = await bootDesktop() + b = await bootDesktop() + await settle() + }) + + it('exports a local split once, and the peer import plus its save-back exports nothing', async () => { + expect(relay.patches).toEqual([]) + + a.driver.write( + withSplit( + { + ...a.driver.readSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } + }, + 'tab-a' + ) + ) + await settle() + + expect(relay.patches).toEqual([{ clientId: a.clientId, baseRevision: 3 }]) + expect(b.imports).toBe(1) + + b.driver.write(withSplit(b.driver.readSession(), 'tab-a', 'horizontal')) + await settle() + + expect(relay.patches).toEqual([ + { clientId: a.clientId, baseRevision: 3 }, + { clientId: b.clientId, baseRevision: 4 } + ]) + expect(a.imports).toBe(1) + expect(relay.revision).toBe(5) + }) + + it("exports a split's binding write and the window's save after it as one patch", async () => { + const split = withSplit( + { ...a.driver.readSession(), tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } }, + 'tab-a' + ) + const layout = split.terminalLayoutsByTabId['tab-a'] + // Main binds the new pane's PTY, then the window saves its focus on that pane. + a.driver.write({ + ...split, + terminalLayoutsByTabId: { + 'tab-a': { ...layout, ptyIdsByLeafId: { 'tab-a-a': 'pty-1', 'tab-a-b': 'pty-2' } } + } + }) + await vi.advanceTimersByTimeAsync(200) + a.driver.write({ + terminalLayoutsByTabId: { + 'tab-a': { + ...a.driver.readSession().terminalLayoutsByTabId['tab-a'], + activeLeafId: 'tab-a-b' + } + } + }) + await settle() + + expect(relay.patches).toEqual([{ clientId: a.clientId, baseRevision: 3 }]) + expect(relay.session.terminalLayoutsByTabId['tab-a']).toMatchObject({ activeLeafId: 'tab-a-b' }) + expect(b.imports).toBe(1) + }) + + it('uploads a tab created during a download once, and the desktops converge', async () => { + // B stands on the shared worktree and A does not, so each merge keeps a different active + // worktree; that difference alone must not bounce uploads between them. + b.driver.write({ activeRepoId: 'repo-1', activeWorktreeId: WORKTREE_ID }) + await settle() + expect(relay.patches).toEqual([{ clientId: b.clientId, baseRevision: 3 }]) + relay.patches = [] + a.imports = 0 + b.unsavedTabs = [tab('tab-b')] + + a.driver.write( + withSplit( + { + ...a.driver.readSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } + }, + 'tab-a' + ) + ) + await settle() + + expect(relay.patches).toEqual([ + { clientId: a.clientId, baseRevision: 4 }, + { clientId: b.clientId, baseRevision: 5 } + ]) + expect(relay.session.tabsByWorktreePath[WORKTREE_PATH]?.map(({ id }) => id)).toEqual([ + 'tab-a', + 'tab-b' + ]) + expect(a.imports).toBe(1) + expect(b.imports).toBe(1) + }) + + it('keeps a desktop whose import conflicted out of exports', async () => { + b.conflicted = true + a.driver.write( + withSplit( + { + ...a.driver.readSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } + }, + 'tab-a' + ) + ) + await settle() + expect(b.imports).toBe(1) + + b.driver.write(withSplit(b.driver.readSession(), 'tab-a', 'horizontal')) + await settle() + + expect(relay.patches).toEqual([{ clientId: a.clientId, baseRevision: 3 }]) + }) +}) diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index cc542920dd83..b963fb180003 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { @@ -52,10 +52,12 @@ vi.mock('./remote-workspace-events', () => ({ registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock })) +import { _resetRemoteWorkspaceCachesForTests } from './remote-workspace' import { - _resetRemoteWorkspaceCachesForTests, - registerRemoteWorkspaceHandlers -} from './remote-workspace' + createRemoteWorkspaceExportDriver, + passExportWindow, + type RemoteWorkspaceExportDriver +} from './remote-workspace-export-test-harness' import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { @@ -76,6 +78,8 @@ const baseSession = { terminalLayoutsByTabId: {} } as WorkspaceSessionState +type PatchParams = { patch: { session: RemoteWorkspaceSession } } + const targets: SshTarget[] = [ { id: 'target-1', @@ -164,29 +168,67 @@ describe('remoteWorkspaceSessionMatchesSnapshot', () => { }) }) -describe('remoteWorkspace:setForConnectedTargets', () => { - const handlers = new Map unknown>() +describe('main exports a session write to the hosts it agrees with', () => { const requestByTargetId = new Map>() const muxByTargetId = new Map }>() const getRepoMock = vi.fn() - const getWorkspaceSessionMock = vi.fn() // Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one. - const getReposMock = vi.fn(() => [getRepoMock('repo-target-1')].filter(Boolean)) - const store = { - getRepo: getRepoMock, - getRepos: getReposMock, - getWorkspaceSession: getWorkspaceSessionMock - } as unknown as Store + const getReposMock = vi.fn(() => { + const repo = getRepoMock('repo-target-1') + return repo ? [repo] : [] + }) + let driver: RemoteWorkspaceExportDriver + + const sessionWithTab: WorkspaceSessionState = { + activeRepoId: 'repo-target-1', + activeWorktreeId: 'repo-target-1::/repo', + activeTabId: 'tab-store', + tabsByWorktree: { + 'repo-target-1::/repo': [ + { + id: 'tab-store', + title: 'Store shell', + ptyId: 'pty-store', + worktreeId: 'repo-target-1::/repo', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: {} + } + + const emptyRemoteSession: RemoteWorkspaceSession = { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + } + + function patchRequests(targetId: string): unknown[][] { + return (requestByTargetId.get(targetId)?.mock.calls ?? []).filter( + ([method]) => method === 'workspace.patch' + ) + } + + async function pushesAfterExportWindow() { + const pushes = driver.nextPushes() + await passExportWindow() + return pushes + } + + afterEach(() => { + vi.useRealTimers() + }) beforeEach(() => { + vi.useFakeTimers() _resetRemoteWorkspaceCachesForTests() - handlers.clear() requestByTargetId.clear() muxByTargetId.clear() vi.mocked(ipcMain.handle).mockReset() - vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { - handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) - }) vi.mocked(ipcMain.removeHandler).mockReset() getSshConnectionStoreMock.mockReset() getSshConnectionStoreMock.mockReturnValue({ @@ -195,8 +237,6 @@ describe('remoteWorkspace:setForConnectedTargets', () => { }) getRepoMock.mockReset() getReposMock.mockClear() - getWorkspaceSessionMock.mockReset() - getWorkspaceSessionMock.mockReturnValue(baseSession) getRepoMock.mockImplementation((repoId: string) => repoId === 'repo-target-1' ? ({ @@ -213,7 +253,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { getActiveMultiplexerMock.mockImplementation((targetId: string) => { let mux = muxByTargetId.get(targetId) if (!mux) { - const request = vi.fn().mockImplementation((method: string) => { + const request = vi.fn().mockImplementation((method: string, params: PatchParams) => { if (method === 'workspace.get') { return Promise.resolve( snapshot({ @@ -226,12 +266,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { } return Promise.resolve({ ok: true, - snapshot: snapshot({ - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }) + snapshot: snapshot(params.patch.session, 8) }) }) mux = { request } @@ -241,209 +276,257 @@ describe('remoteWorkspace:setForConnectedTargets', () => { return mux }) registerRemoteWorkspaceNotificationHandlerMock.mockClear() - - registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) + driver = createRemoteWorkspaceExportDriver({ getRepo: getRepoMock, getRepos: getReposMock }) }) - async function callSetForConnectedTargets(args: { - session?: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - }): Promise { - const handler = handlers.get('remoteWorkspace:setForConnectedTargets') - if (!handler) { - throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') - } - return handler(null, args) - } - async function observeTarget(targetId: string): Promise { - const handler = handlers.get('remoteWorkspace:get') - if (!handler) { - throw new Error('remoteWorkspace:get handler was never registered') - } - const observed = await handler(null, { targetId }) + const observed = await driver.handlers.get('remoteWorkspace:get')?.(null, { targetId }) if (!observed || typeof observed !== 'object' || !('hostObservationToken' in observed)) { throw new Error(`remoteWorkspace:get did not observe ${targetId}`) } return observed as RemoteWorkspaceObservedSnapshot } - it('reads the repo catalog once per publish, not once per worktree', async () => { + async function agreeWith(targetId: string): Promise { + const { revision, hostObservationToken } = await observeTarget(targetId) + driver.agree(targetId, { revision, hostObservationToken }) + } + + it('reads the repo catalog once per export, not once per worktree', async () => { // `store.getRepos()` re-hydrates every repo row. The export asks "is this worktree mine?" once // per worktree, so reading the catalog inside that callback multiplied hydration by the // worktree count — 413 on the session that surfaced this. + await agreeWith('target-1') + await Promise.resolve() + getReposMock.mockClear() const worktrees = Object.fromEntries( Array.from({ length: 12 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) ) - getWorkspaceSessionMock.mockReturnValue({ - ...baseSession, - tabsByWorktree: worktrees - } as WorkspaceSessionState) - const observed = await observeTarget('target-1') - getReposMock.mockClear() - await callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': observed.revision }, - expectedHostObservationTokensByTargetId: { - 'target-1': observed.hostObservationToken - } - }) + driver.write({ ...baseSession, tabsByWorktree: worktrees }) + await pushesAfterExportWindow() expect(getReposMock).toHaveBeenCalledTimes(1) }) - it('resolves each worktree ownership once for the whole publish, not once per target', async () => { + it('resolves each worktree ownership once for the whole export, not once per target', async () => { // Ownership is a function of the repo catalog alone; only the final `=== targetId` differs, so // exporting to N targets used to repeat the identical resolution N times per worktree key. + for (const target of targets) { + await agreeWith(target.id) + } + await Promise.resolve() + getReposMock.mockClear() + resolveWorktreeExecutionHostCalls.count = 0 const worktrees = Object.fromEntries( Array.from({ length: 6 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) ) - getWorkspaceSessionMock.mockReturnValue({ - ...baseSession, - tabsByWorktree: worktrees - } as WorkspaceSessionState) - const observed = await Promise.all(targets.map((target) => observeTarget(target.id))) - getReposMock.mockClear() - resolveWorktreeExecutionHostCalls.count = 0 - await callSetForConnectedTargets({ - hydratedTargetIds: targets.map((target) => target.id), - expectedRevisionsByTargetId: Object.fromEntries( - targets.map((target, index) => [target.id, observed[index].revision]) - ), - expectedHostObservationTokensByTargetId: Object.fromEntries( - targets.map((target, index) => [target.id, observed[index].hostObservationToken]) - ) - }) + driver.write({ ...baseSession, tabsByWorktree: worktrees }) + await pushesAfterExportWindow() expect(getReposMock).toHaveBeenCalledTimes(1) - // 6 worktree keys resolved once each, regardless of how many targets are published to. + // 6 worktree keys resolved once each, regardless of how many targets are exported to. expect(resolveWorktreeExecutionHostCalls.count).toBe(6) }) - it('skips the session and repo-catalog reads when no hydrated target is connected', async () => { - // A hydrated but disconnected target leaves nothing to project onto, so hoisting the catalog - // read must not make the idle path pay for a full repo hydration it never used before. + it('skips the session and repo-catalog reads when no agreed target is connected', async () => { + // A disconnected target leaves nothing to project onto, so a session write must not pay for a + // full repo hydration it never uses. + driver.agree('target-1', { revision: 7, hostObservationToken: 'token' }) getActiveMultiplexerMock.mockReturnValue(undefined) + await Promise.resolve() getReposMock.mockClear() - getWorkspaceSessionMock.mockClear() - await expect( - callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': 'token' } - }) - ).resolves.toEqual([]) + driver.write(sessionWithTab) + await passExportWindow() expect(getReposMock).not.toHaveBeenCalled() - expect(getWorkspaceSessionMock).not.toHaveBeenCalled() + expect(driver.pushes).toEqual([]) }) - it('does not write without an explicit non-empty hydrated target set', async () => { - await expect(callSetForConnectedTargets({ session: baseSession })).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: [] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1', 42] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1'] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 } - }) - ).resolves.toEqual([]) - - expect(getSshConnectionStoreMock).not.toHaveBeenCalled() + it('exports nothing to a host this desktop has no valid agreement with', async () => { + driver.importPeer({ + targetId: 'target-1', + revision: -1, + hostObservationToken: 'token', + outcome: 'synced', + session: {} + }) + driver.importPeer({ + targetId: 'target-1', + revision: 7, + hostObservationToken: '', + outcome: 'synced', + session: {} + }) + + driver.write(sessionWithTab) + await passExportWindow() + expect(getActiveMultiplexerMock).not.toHaveBeenCalled() + expect(driver.pushes).toEqual([]) }) - it('writes only to explicitly hydrated connected targets', async () => { - const observation = await observeTarget('target-1') - const result = await callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1', 'missing-target'], - expectedRevisionsByTargetId: { 'target-1': 7, 'missing-target': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': observation.hostObservationToken, - 'missing-target': 'unreachable-target-observation' - } - }) + it('writes only to agreed connected targets', async () => { + await agreeWith('target-1') + driver.agree('missing-target', { revision: 7, hostObservationToken: 'unreachable' }) + + driver.write(sessionWithTab) + const [push] = await pushesAfterExportWindow() - expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }]) - expect(getActiveMultiplexerMock).toHaveBeenCalledWith('target-1') + expect(push).toMatchObject({ targetId: 'target-1', result: { ok: true } }) expect(getActiveMultiplexerMock).not.toHaveBeenCalledWith('target-2') - expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( - 'workspace.patch', - expect.objectContaining({ - patch: expect.objectContaining({ kind: 'replace-session' }) - }) - ) + expect(patchRequests('target-1')).toEqual([ + [ + 'workspace.patch', + expect.objectContaining({ patch: expect.objectContaining({ kind: 'replace-session' }) }) + ] + ]) expect(requestByTargetId.get('target-2')).toBeUndefined() }) - it('can export from the persisted store session when no session argument is provided', async () => { - getWorkspaceSessionMock.mockReturnValue({ - activeRepoId: 'repo-target-1', - activeWorktreeId: 'repo-target-1::/repo', - activeTabId: 'tab-store', - tabsByWorktree: { - 'repo-target-1::/repo': [ - { - id: 'tab-store', - title: 'Store shell', - ptyId: 'pty-store', - worktreeId: 'repo-target-1::/repo' - } as never - ] - }, - terminalLayoutsByTabId: {} - }) + it('exports the persisted store session', async () => { + await agreeWith('target-1') - const observation = await observeTarget('target-1') - await callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': observation.hostObservationToken - } - }) + driver.write(sessionWithTab) + await pushesAfterExportWindow() - expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( - 'workspace.patch', - expect.objectContaining({ - patch: expect.objectContaining({ - session: expect.objectContaining({ - activeWorktreePath: '/repo', - activeTabId: 'tab-store' + expect(patchRequests('target-1')).toEqual([ + [ + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + activeWorktreePath: '/repo', + activeTabId: 'tab-store' + }) }) }) - }) - ) + ] + ]) }) - it('does not invalidate an upload authority when an unchanged snapshot is polled', async () => { + it('does not invalidate an agreement when an unchanged snapshot is polled', async () => { const first = await observeTarget('target-1') const second = await observeTarget('target-1') expect(second.hostObservationToken).toBe(first.hostObservationToken) + driver.agree('target-1', first) - const result = await callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': first.revision }, - expectedHostObservationTokensByTargetId: { - 'target-1': first.hostObservationToken - } + driver.write(sessionWithTab) + + await expect(pushesAfterExportWindow()).resolves.toMatchObject([ + { targetId: 'target-1', result: { ok: true } } + ]) + }) + + it('exports nothing for an import, and once for the next local change', async () => { + const observed = await observeTarget('target-1') + driver.importPeer({ + ...observed, + targetId: 'target-1', + outcome: 'synced', + session: sessionWithTab }) + // The window saves back what it applied from the import. + driver.write(sessionWithTab) + await passExportWindow() + expect(patchRequests('target-1')).toEqual([]) + + driver.write({ ...sessionWithTab, activeTabId: null }) + await pushesAfterExportWindow() + expect(patchRequests('target-1')).toHaveLength(1) + }) + + it('keeps the agreement after an unavailable relay, so the next local change retries', async () => { + await agreeWith('target-1') + requestByTargetId + .get('target-1') + ?.mockImplementationOnce(async () => ({ ok: false, reason: 'unavailable' })) + + driver.write(sessionWithTab) + await expect(pushesAfterExportWindow()).resolves.toMatchObject([ + { result: { ok: false, reason: 'unavailable' } } + ]) + driver.write({ ...sessionWithTab, activeTabId: null }) + await expect(pushesAfterExportWindow()).resolves.toMatchObject([{ result: { ok: true } }]) + + expect(patchRequests('target-1')).toHaveLength(2) + }) + + it('exports nothing over a new connection until a pull on it agrees', async () => { + // A relay restart retires the dead PTYs' tabs here before the window re-pulls; exporting + // that under the old connection's agreement erased the tabs the pull would have restored. + await agreeWith('target-1') + muxByTargetId.delete('target-1') + + driver.write(sessionWithTab) + await passExportWindow() + expect(patchRequests('target-1')).toEqual([]) - expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }]) + await agreeWith('target-1') + driver.write({ ...sessionWithTab, activeTabId: null }) + await pushesAfterExportWindow() + expect(patchRequests('target-1')).toHaveLength(1) + }) + + it('reports nothing for an export a newer pull superseded', async () => { + const observed = await observeTarget('target-1') + driver.agree('target-1', observed) + let finishPatch: (result: unknown) => void = () => {} + const request = requestByTargetId.get('target-1') + request?.mockImplementationOnce( + () => + new Promise((resolve) => { + finishPatch = resolve + }) + ) + + driver.write(sessionWithTab) + await vi.waitFor(() => expect(patchRequests('target-1')).toHaveLength(1)) + driver.importPeer({ ...observed, targetId: 'target-1', outcome: 'synced', session: {} }) + finishPatch({ ok: false, reason: 'stale-revision', snapshot: snapshot(emptyRemoteSession, 9) }) + await passExportWindow() + + expect(driver.pushes).toEqual([]) + }) + + it('keeps a conflicted target out of exports until a whole pull agrees again', async () => { + const observed = await observeTarget('target-1') + driver.agree('target-1', observed) + driver.agree('target-1', observed, 'conflict') + + driver.write(sessionWithTab) + await passExportWindow() + expect(patchRequests('target-1')).toEqual([]) + + driver.agree('target-1', observed) + driver.write({ ...sessionWithTab, activeTabId: null }) + await pushesAfterExportWindow() + expect(patchRequests('target-1')).toHaveLength(1) + }) + + it.each([ + ['seeds an empty host from a desktop that has tabs for it', true], + ['leaves an empty host alone when this desktop has nothing for it', false] + ])('%s', async (_name, hasTabs) => { + const request = vi.fn(async (method: string, params: Partial) => + method === 'workspace.get' + ? snapshot(emptyRemoteSession, 0) + : { ok: true, snapshot: snapshot(params.patch?.session ?? emptyRemoteSession, 1) } + ) + muxByTargetId.set('target-1', { request }) + requestByTargetId.set('target-1', request) + driver.write(hasTabs ? sessionWithTab : baseSession) + const observed = await observeTarget('target-1') + expect(observed.revision).toBe(0) + + driver.agree('target-1', observed, 'kept-local') + + await passExportWindow() + expect(patchRequests('target-1')).toHaveLength(hasTabs ? 1 : 0) + expect(driver.pushes).toMatchObject( + hasTabs ? [{ targetId: 'target-1', authority: { revision: 0 }, result: { ok: true } }] : [] + ) }) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 43441b381bee..bedba0602275 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -5,31 +5,26 @@ import { REMOTE_WORKSPACE_CHANGED_NOTIFICATION, REMOTE_WORKSPACE_STALE_NOTIFICATION, type RemoteWorkspaceChangedEvent, - type RemoteWorkspaceObservedPatchResult, - type RemoteWorkspaceObservedSnapshot + type RemoteWorkspaceObservedSnapshot, + type RemoteWorkspacePeerImport, + type RemoteWorkspacePushStatusEvent } from '../../shared/remote-workspace-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { createRepoRowExecutionHostLookup } from '../../shared/worktree-execution-host-resolution' -import { - createWorktreeOwnerResolver, - createWorktreeTargetResolver, - exportSessionForTarget, - persistedSessionForTarget -} from './remote-workspace-target-session-export' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' +import { importPeerTopology } from '../persistence/terminal-topology/terminal-topology-commit' +import { createRemoteWorkspaceExports } from './remote-workspace-export' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' import { CLIENT_ID, type RemoteWorkspaceClientNameSource } from './remote-workspace-client-identity' import { listRemoteWorkspaceConnectedClients } from './remote-workspace-connected-clients' import { clearRemoteWorkspacePatchTails, - getRemoteWorkspacePatchTailCount, - queueRemoteWorkspacePatch + getRemoteWorkspacePatchTailCount } from './remote-workspace-patch-queue' -import { getRemoteSnapshot, patchRemoteWorkspaceSession } from './remote-workspace-relay-sync' +import { getRemoteSnapshot } from './remote-workspace-relay-sync' import { - cachedRemoteWorkspaceSnapshotAuthorizesRevision, clearRemoteWorkspaceSnapshotCache, - getCachedRemoteWorkspaceSnapshot, getRemoteWorkspaceSnapshotCacheSize, rememberLocallyPatchedRemoteWorkspaceSnapshot, rememberRemoteWorkspaceSnapshot @@ -42,6 +37,7 @@ import { let mainWindowGetter: (() => BrowserWindow | null) | null = null let unregisterRemoteWorkspaceNotifications: (() => void) | null = null +let unsubscribeSessionWrites: (() => void) | null = null export function _resetRemoteWorkspaceCachesForTests(): void { clearRemoteWorkspaceSnapshotCache() @@ -59,51 +55,21 @@ export function _getRemoteWorkspaceCacheSizesForTests(): { } } -function getExplicitHydratedTargetIds(value: unknown): Set | null { - if ( - !Array.isArray(value) || - value.length === 0 || - value.some((targetId) => typeof targetId !== 'string' || targetId.length === 0) - ) { - return null - } - return new Set(value) -} - -function getExpectedTargetRevisions( - value: unknown, - targetIds: ReadonlySet -): Map | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const revisions = new Map() - for (const targetId of targetIds) { - const revision = (value as Record)[targetId] - if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0) { - return null - } - revisions.set(targetId, revision) - } - return revisions -} - -function getExpectedHostObservationTokens( - value: unknown, - targetIds: ReadonlySet -): Map | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const tokens = new Map() - for (const targetId of targetIds) { - const token = (value as Record)[targetId] - if (typeof token !== 'string' || token.length === 0 || token.length > 128) { - return null - } - tokens.set(targetId, token) - } - return tokens +/** A malformed pull records nothing, so the target stays out of exports until a valid one. */ +function isValidPeerImport(pull: RemoteWorkspacePeerImport): boolean { + return ( + typeof pull.targetId === 'string' && + pull.targetId.length > 0 && + Number.isSafeInteger(pull.revision) && + pull.revision >= 0 && + typeof pull.hostObservationToken === 'string' && + pull.hostObservationToken.length > 0 && + pull.hostObservationToken.length <= 128 && + ['synced', 'kept-local', 'conflict'].includes(pull.outcome) && + typeof pull.session === 'object' && + pull.session !== null && + !Array.isArray(pull.session) + ) } function sendRemoteWorkspaceChanged( @@ -122,6 +88,13 @@ function sendRemoteWorkspaceChanged( } } +function sendRemoteWorkspacePushStatus(event: RemoteWorkspacePushStatusEvent): void { + const win = mainWindowGetter?.() + if (win && !win.isDestroyed()) { + win.webContents.send('remoteWorkspace:pushStatus', event) + } +} + export function handleRemoteWorkspaceNotification( targetId: string, method: string, @@ -160,15 +133,19 @@ export function handleRemoteWorkspaceNotification( export function registerRemoteWorkspaceHandlers( store: Store, getMainWindow: () => BrowserWindow | null, - clientNameSource: RemoteWorkspaceClientNameSource + runtime: RemoteWorkspaceClientNameSource & + Partial> ): void { mainWindowGetter = getMainWindow unregisterRemoteWorkspaceNotifications?.() unregisterRemoteWorkspaceNotifications = registerRemoteWorkspaceNotificationHandler( handleRemoteWorkspaceNotification ) + const exports = createRemoteWorkspaceExports(store, sendRemoteWorkspacePushStatus) + unsubscribeSessionWrites?.() + unsubscribeSessionWrites = store.onWorkspaceSessionWritten(exports.exportChanged) ipcMain.removeHandler('remoteWorkspace:get') - ipcMain.removeHandler('remoteWorkspace:setForConnectedTargets') + ipcMain.removeHandler('remoteWorkspace:importPeerTopology') ipcMain.removeHandler('remoteWorkspace:listEnabledConnectedTargets') ipcMain.removeHandler('remoteWorkspace:listConnectedClients') ipcMain.removeHandler('remoteWorkspace:clientId') @@ -181,90 +158,27 @@ export function registerRemoteWorkspaceHandlers( return getRemoteSnapshot(target) }) + // The window imports a host's snapshot through main: only that host's partition is written, and + // the agreement it sets keeps the import from exporting back. ipcMain.handle( - 'remoteWorkspace:setForConnectedTargets', - async ( - _event, - args: { - session?: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - } - ) => { - const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds) - if (!hydratedTargetIds) { - // Why: an omitted hydration set used to broadcast one session to every - // SSH target, overwriting unrelated remote workspace snapshots. - return [] - } - const expectedRevisions = getExpectedTargetRevisions( - args.expectedRevisionsByTargetId, - hydratedTargetIds - ) - if (!expectedRevisions) { - return [] + 'remoteWorkspace:importPeerTopology', + (_event, pull: RemoteWorkspacePeerImport | undefined) => { + if (!pull || !isValidPeerImport(pull)) { + return } - const expectedHostObservationTokens = getExpectedHostObservationTokens( - args.expectedHostObservationTokensByTargetId, - hydratedTargetIds - ) - if (!expectedHostObservationTokens) { - return [] - } - const targets = - getSshConnectionStore() - ?.listTargets() - .filter( - (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) - ) ?? [] - - if (targets.length === 0) { - // Nothing to project onto, so skip the session and repo-catalog reads entirely. - return [] - } - - // One repo read, and ownership resolutions shared across targets: neither depends on the - // target. The publish fallback's catalog attribution reads the same lookup for the same - // reason — building it per target re-hydrates every repo row once per connected host. - const resolveWorktreeOwner = createWorktreeOwnerResolver( - createRepoRowExecutionHostLookup(store.getRepos()) - ) - const resolveWorktreeTarget = createWorktreeTargetResolver(resolveWorktreeOwner) - const results = await Promise.all( - targets.map(async (target) => { - // Why: each target has its own revision stream. Keep same-target - // writes queued, but do not let one slow relay block others. - const session = exportSessionForTarget( - resolveWorktreeTarget, - target.id, - args.session ?? persistedSessionForTarget(store, target.id, resolveWorktreeOwner) - ) - const result = await queueRemoteWorkspacePatch(target.id, async () => { - const current = - getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) - const expectedRevision = expectedRevisions.get(target.id) - const expectedHostObservationToken = expectedHostObservationTokens.get(target.id) - if ( - !current || - expectedRevision === undefined || - expectedHostObservationToken === undefined || - current.hostObservationToken !== expectedHostObservationToken || - !cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, expectedRevision) - ) { - const latest = getCachedRemoteWorkspaceSnapshot(target.id) ?? current - return latest - ? ({ ok: false, reason: 'stale-revision', snapshot: latest } as const) - : null - } - return patchRemoteWorkspaceSession(target, session) - }) - return result ? { targetId: target.id, result } : null - }) - ) - return results.filter( - (entry): entry is { targetId: string; result: RemoteWorkspaceObservedPatchResult } => - entry !== null + const keptMainRows = + !isFrozenOrcadSourceSessionPartition(store, toSshExecutionHostId(pull.targetId)) && + importPeerTopology( + store, + pull.targetId, + pull.session, + (worktreeId) => + (runtime.settleTerminalTopology?.(worktreeId) ?? 0) > + (pull.mirroredTopologySeqByWorktree?.[worktreeId] ?? 0) + ) + // Rows main kept over the pull are news to the host, so they export. + exports.recordPull( + keptMainRows && pull.outcome === 'synced' ? { ...pull, outcome: 'kept-local' } : pull ) } ) @@ -281,7 +195,7 @@ export function registerRemoteWorkspaceHandlers( ipcMain.handle( 'remoteWorkspace:listConnectedClients', async (_event, args?: { targetIds?: string[] }) => - listRemoteWorkspaceConnectedClients(args, clientNameSource) + listRemoteWorkspaceConnectedClients(args, runtime) ) ipcMain.handle('remoteWorkspace:clientId', () => CLIENT_ID) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index 43491ba31af4..ed1ec159ae65 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -24,6 +24,7 @@ vi.mock('electron', () => ({ } })) +import { getDefaultWorkspaceSession } from '../../shared/constants' import { registerRendererShutdownCheckpointHandler, SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS @@ -41,6 +42,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('stages every shutdown mutation before queueing persistence', () => { const callOrder: string[] = [] const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn((_state, hostId?: string) => { callOrder.push(`session:${hostId ?? 'local'}`) }), @@ -64,12 +66,12 @@ describe('registerRendererShutdownCheckpointHandler', () => { expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenNthCalledWith( 1, - localSession, + expect.objectContaining(localSession), undefined ) expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenNthCalledWith( 2, - remoteSession, + expect.objectContaining(remoteSession), 'runtime:host-1' ) expect(store.updateUI).toHaveBeenCalledWith({ activeView: 'settings' }) @@ -84,6 +86,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('never stages the source partition of a fenced host', () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), getSshTarget: vi.fn(() => ({ orcadFence: { environmentId: 'env-1' } })), updateUI: vi.fn(), @@ -98,12 +101,16 @@ describe('registerRendererShutdownCheckpointHandler', () => { }) expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenCalledTimes(1) - expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenCalledWith({}, undefined) + expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenCalledWith( + expect.any(Object), + undefined + ) expect(event.returnValue).toEqual({ ok: true }) }) it('reports a staging failure so the renderer can retry', () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(() => { throw new Error('disk full') @@ -121,6 +128,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('does not queue persistence when staging is incomplete', async () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) @@ -141,6 +149,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('stages synchronously without waiting on the durable write', () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) @@ -157,6 +166,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('holds the checkpoint open until the durable write settles', async () => { let resolveFlush!: () => void const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn( @@ -184,6 +194,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('reports a failed durable write instead of a successful checkpoint', async () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => Promise.reject(new Error('disk full'))) @@ -200,6 +211,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('fails the checkpoint when the durable write outlives its deadline', async () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn( @@ -224,6 +236,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('reports success before any checkpoint is staged', async () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index 82fcbee0d4e2..da5091493811 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -4,6 +4,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 { stageRendererSessionBeforeUnload } from '../persistence/terminal-topology/terminal-renderer-presentation-save' type StageBeforeUnloadSyncArgs = { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] @@ -51,7 +52,7 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void { try { for (const { state, hostId } of args.sessions) { if (!isFrozenOrcadSourceSessionPartition(store, hostId)) { - store.stageWorkspaceSessionBeforeUnload(state, hostId) + stageRendererSessionBeforeUnload(store, state, hostId) } } store.updateUI(args.ui) diff --git a/src/main/ipc/session-close-terminal-surface.test.ts b/src/main/ipc/session-close-terminal-surface.test.ts index 909c9c43c651..116d00f7c61a 100644 --- a/src/main/ipc/session-close-terminal-surface.test.ts +++ b/src/main/ipc/session-close-terminal-surface.test.ts @@ -22,7 +22,13 @@ const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' const OTHER_LEAF = '6fa459ea-ee8a-4ca4-894e-db77e160355e' -const closeTerminalSurfaceFromRenderer = vi.fn(async () => {}) +let committed = false +const closeTerminalSurfaceFromRenderer = vi.fn(async () => { + await Promise.resolve() + committed = true +}) +// Names the push only once the close has committed. +const settleTerminalTopology = vi.fn(() => (committed ? 7 : 0)) function closeSurface(args: unknown): unknown { return mocks.handlers.get('session:close-terminal-surface')?.({}, args) @@ -35,8 +41,9 @@ function launchIn(leafId: string) { beforeEach(() => { mocks.handlers.clear() closeTerminalSurfaceFromRenderer.mockClear() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler under test reaches only the runtime's renderer close. - const runtime = { closeTerminalSurfaceFromRenderer } as never + committed = false + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler under test reaches only the runtime's renderer close and topology settle. + const runtime = { closeTerminalSurfaceFromRenderer, settleTerminalTopology } as never // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close handler never touches the store. registerSessionHandlers({} as never, runtime) }) @@ -92,4 +99,11 @@ describe("the user's close of a launch's tab or pane", () => { expect(running.closedByUser()).toBe(false) }) + + it('replies with the publishSeq of the push that carries the close', async () => { + await expect( + closeSurface({ worktreeId: WT, target: { kind: 'tab', tabId: TAB }, reason: 'user' }) + ).resolves.toEqual({ publishSeq: 7 }) + expect(settleTerminalTopology).toHaveBeenLastCalledWith(WT) + }) }) diff --git a/src/main/ipc/session-terminal-bind-leaf.test.ts b/src/main/ipc/session-terminal-bind-leaf.test.ts new file mode 100644 index 000000000000..5387e9d98595 --- /dev/null +++ b/src/main/ipc/session-terminal-bind-leaf.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import type { Store } from '../persistence' +import { + closeMoveTestStores, + LEFT, + MOVED, + newDataFile, + openStore, + seedSplitSource, + SOURCE, + WT +} from '../persistence/terminal-topology/terminal-leaf-move-fixture' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { + handle: (name: string, callback: (...args: unknown[]) => unknown) => + mocks.handlers.set(name, callback), + on: () => {} + }, + BrowserWindow: { getAllWindows: () => [] } +})) +import { registerSessionHandlers } from './session' + +const ADOPT = { worktreeId: WT, tabId: SOURCE, leafId: MOVED, ptyId: 'pty-adopted' } +let homeHostId: string | null = 'local' +let store: Store + +function bindLeaf(args: unknown): Promise { + return Promise.resolve(mocks.handlers.get('session:terminal-bind-leaf')?.({}, args)) +} + +// A split whose second pane lost its binding while its agent kept running. +beforeEach(async () => { + mocks.handlers.clear() + homeHostId = 'local' + store = openStore(newDataFile()) + await seedSplitSource(store) + await store.retirePtyBinding({ worktreeId: WT, tabId: SOURCE, leafId: MOVED, ptyId: 'pty-agent' }) + registerSessionHandlers( + store, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reaches only the home lookup and the topology settle. + { getTerminalTopologyHomeHostId: () => homeHostId, settleTerminalTopology: () => 4 } as never + ) +}) + +afterEach(closeMoveTestStores) + +describe('session:terminal-bind-leaf', () => { + it("records an adopted agent's PTY on its pane in a split, so a remount reattaches", async () => { + await expect(bindLeaf(ADOPT)).resolves.toEqual({ status: 'bound', publishSeq: 4 }) + + expect(store.getWorkspaceSession().terminalLayoutsByTabId[SOURCE]?.ptyIdsByLeafId).toEqual({ + [LEFT]: 'pty-left', + [MOVED]: 'pty-adopted' + }) + }) + + it('never creates a pane main does not hold', async () => { + const before = store.getWorkspaceSession() + + await expect( + bindLeaf({ ...ADOPT, leafId: '33333333-3333-4333-8333-333333333333' }) + ).resolves.toEqual({ status: 'refused', reason: 'not_bound', publishSeq: 4 }) + expect(store.getWorkspaceSession()).toEqual(before) + }) + + it('writes nothing when the home is unresolved, or for a malformed request', async () => { + const before = store.getWorkspaceSession() + homeHostId = null + + await expect(bindLeaf(ADOPT)).resolves.toEqual({ + status: 'refused', + reason: 'home_unresolved', + publishSeq: 4 + }) + await expect(bindLeaf({ ...ADOPT, leafId: 'not-a-leaf' })).resolves.toEqual({ + status: 'refused', + reason: 'invalid_request' + }) + expect(store.getWorkspaceSession()).toEqual(before) + }) +}) diff --git a/src/main/ipc/session-terminal-set-layout.test.ts b/src/main/ipc/session-terminal-set-layout.test.ts new file mode 100644 index 000000000000..2a4cf144a2ba --- /dev/null +++ b/src/main/ipc/session-terminal-set-layout.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, callback: (...args: unknown[]) => unknown) => + mocks.handlers.set(name, callback), + on: vi.fn() + } +})) +import { registerSessionHandlers } from './session' + +const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' +const REQUEST = { worktreeId: 'repo::/wt', tabId: 'tab', root: { type: 'leaf', leafId: LEAF } } + +let committed = false +const setTerminalTabLayout = vi.fn(async () => { + await Promise.resolve() + committed = true + return { status: 'committed' as const } +}) +// Names the push only once the commit has landed. +const settleTerminalTopology = vi.fn(() => (committed ? 9 : 0)) +let homeHostId: string | null = 'local' +const getTerminalTopologyHomeHostId = vi.fn(() => homeHostId) + +function setLayout(args: unknown): unknown { + return mocks.handlers.get('session:terminal-set-layout')?.({}, args) +} + +beforeEach(() => { + mocks.handlers.clear() + setTerminalTabLayout.mockClear() + committed = false + homeHostId = 'local' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reaches only the store's layout commit. + const store = { setTerminalTabLayout } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reaches only the home lookup and the topology settle. + registerSessionHandlers(store, { getTerminalTopologyHomeHostId, settleTerminalTopology } as never) +}) + +describe('session:terminal-set-layout', () => { + it('replies with the publishSeq of the push holding the commit', async () => { + await expect(setLayout(REQUEST)).resolves.toEqual({ status: 'committed', publishSeq: 9 }) + expect(setTerminalTabLayout).toHaveBeenCalledWith(REQUEST, 'local') + expect(settleTerminalTopology).toHaveBeenCalledWith(REQUEST.worktreeId) + }) + + it('refuses a malformed request without touching the store', async () => { + await expect(setLayout({ ...REQUEST, root: { type: 'leaf' } })).resolves.toEqual({ + status: 'refused', + reason: 'invalid_request' + }) + expect(setTerminalTabLayout).not.toHaveBeenCalled() + }) + + it('writes nothing when the worktree has no resolvable home partition', async () => { + homeHostId = null + await expect(setLayout(REQUEST)).resolves.toEqual({ + status: 'refused', + reason: 'home_unresolved', + publishSeq: 0 + }) + expect(setTerminalTabLayout).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/session.test.ts b/src/main/ipc/session.test.ts index 32f4ac8d6c65..9534e9b85725 100644 --- a/src/main/ipc/session.test.ts +++ b/src/main/ipc/session.test.ts @@ -25,14 +25,40 @@ vi.mock('electron', () => ({ } })) +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint' import { registerSessionHandlers } from './session' +const WORKTREE = 'repo::/worktree' + +function sessionWithTab(tabId: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE]: [ + { + id: tabId, + ptyId: null, + worktreeId: WORKTREE, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } +} + describe('registerSessionHandlers', () => { it('drops renderer writes to a fenced host source partition and keeps every other one', async () => { const store = { getSshTarget: vi.fn((id: string) => id === 'fenced' ? { orcadFence: { environmentId: 'env-1' } } : {} ), + getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()), setWorkspaceSession: vi.fn(), patchWorkspaceSession: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) @@ -58,4 +84,79 @@ describe('registerSessionHandlers', () => { await vi.waitFor(() => expect(event.returnValue).toBe(true)) expect(store.setWorkspaceSession).toHaveBeenCalledTimes(2) }) + + it("refuses a window's topology commits to a fenced home and writes nothing", async () => { + const store = { + getSshTarget: vi.fn(() => ({ orcadFence: { environmentId: 'env-1' } })), + getWorkspaceSessionHostIds: vi.fn(() => ['ssh:fenced']), + getWorkspaceSession: vi.fn(() => sessionWithTab('tab')), + patchWorkspaceSession: vi.fn(), + createTerminalSurface: vi.fn(), + setTerminalTabLayout: vi.fn(), + persistPtyBinding: vi.fn() + } + const runtime = { + getTerminalTopologyHomeHostId: vi.fn(() => 'ssh:fenced'), + settleTerminalTopology: vi.fn(() => 0) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test touch only the store and runtime methods stubbed above. + registerSessionHandlers(store as never, runtime as never) + const leafId = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + const pane = { worktreeId: WORKTREE, tabId: 'tab', leafId } + const refused = { status: 'refused', reason: 'home_unresolved', publishSeq: 0 } + + await expect( + handlers.get('session:terminal-create-surface')?.( + {}, + { ...pane, placement: { kind: 'root' } } + ) + ).resolves.toEqual(refused) + await expect( + handlers.get('session:terminal-set-layout')?.( + {}, + { worktreeId: WORKTREE, tabId: 'tab', root: { type: 'leaf', leafId } } + ) + ).resolves.toEqual(refused) + await expect( + handlers.get('session:terminal-bind-leaf')?.({}, { ...pane, ptyId: 'pty-1' }) + ).resolves.toEqual(refused) + handlers.get('session:terminal-clear-launch-agent')?.( + {}, + { worktreeId: WORKTREE, tabId: 'tab' } + ) + expect(store.createTerminalSurface).not.toHaveBeenCalled() + expect(store.setTerminalTabLayout).not.toHaveBeenCalled() + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(store.patchWorkspaceSession).not.toHaveBeenCalled() + }) + + it('set, set-sync, patch and the quit stage all keep the tabs main holds', async () => { + const written: WorkspaceSessionState['tabsByWorktree'][] = [] + const record = (session: Partial): void => { + written.push(session.tabsByWorktree ?? {}) + } + const store = { + getSshTarget: vi.fn(() => ({})), + getWorkspaceSession: vi.fn(() => sessionWithTab('main-tab')), + setWorkspaceSession: vi.fn(record), + patchWorkspaceSession: vi.fn(record), + stageWorkspaceSessionBeforeUnload: vi.fn(record), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test touch only the store methods stubbed above and never the runtime. + registerSessionHandlers(store as never, {} as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above. + registerRendererShutdownCheckpointHandler(store as never) + const stale = sessionWithTab('stale-tab') + + await handlers.get('session:set')?.({}, stale) + await handlers.get('session:patch')?.({}, { tabsByWorktree: stale.tabsByWorktree }) + const event: { returnValue?: unknown } = {} + handlers.get('session:set-sync')?.(event, stale) + await vi.waitFor(() => expect(event.returnValue).toBe(true)) + handlers.get('app:stage-before-unload-sync')?.({}, { sessions: [{ state: stale }], ui: {} }) + + expect(written).toEqual(Array(4).fill(sessionWithTab('main-tab').tabsByWorktree)) + }) }) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 11b118c87315..eacbdf4b6cea 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -2,17 +2,45 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { parseTerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' +import { + parseTerminalLayoutSetRequest, + type TerminalLayoutSetResult +} from '../../shared/terminal-layout-set' +import { + parseTerminalSurfaceCreateRequest, + type TerminalSurfaceCreateResult +} from '../../shared/terminal-surface-create' +import { + parseTerminalLeafBindRequest, + type TerminalLeafBindResult +} from '../../shared/terminal-leaf-bind' +import type { ExecutionHostId } from '../../shared/execution-host' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { sleepingAgentSessionsByPaneKeySchema } from '../../shared/workspace-session-sleeping-agents' +import { + bindLeaf, + clearLaunchAgent, + commitSleepingRecords +} from '../persistence/terminal-topology/terminal-topology-commit' +import { + patchRendererSession, + setRendererSession +} from '../persistence/terminal-topology/terminal-renderer-presentation-save' export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeService): void { // Why: renderer saves would change a fenced host's frozen source partition. const isFenced = (hostId?: string | null): boolean => isFrozenOrcadSourceSessionPartition(store, hostId) + // A window's topology commits replace its saves, so a fenced home is no home for them either. + const windowWritableHome = (worktreeId: string): ExecutionHostId | null => { + const hostId = runtime.getTerminalTopologyHomeHostId(worktreeId) + return hostId && !isFenced(hostId) ? hostId : null + } // Why: hostId is an optional second arg so an older renderer that invokes // these channels without it keeps reading/writing the 'local' partition @@ -30,20 +58,23 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => { if (!isFenced(hostId)) { - store.setWorkspaceSession(args, hostId) + setRendererSession(store, args, hostId) } }) ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => { if (!isFenced(hostId)) { - store.patchWorkspaceSession(args, hostId) + patchRendererSession(store, args, hostId) } }) // Why: a renderer save cannot shrink membership main owns, so each close commits it explicitly. ipcMain.handle( 'session:close-terminal-surface', - (_event, args: { worktreeId?: unknown; target?: unknown; reason?: unknown } | undefined) => { + async ( + _event, + args: { worktreeId?: unknown; target?: unknown; reason?: unknown } | undefined + ) => { const target = parseTerminalSurfaceCloseTarget(args?.target) if (typeof args?.worktreeId !== 'string' || !target) { throw new Error('invalid_terminal_surface') @@ -54,14 +85,90 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic // A launch still starting or delivering in what the user closed stops, and says so. markAgentLaunchesClosedByUser(args.worktreeId, target) } - return runtime.closeTerminalSurfaceFromRenderer({ + await runtime.closeTerminalSurfaceFromRenderer({ worktreeId: args.worktreeId, target, reason }) + return { publishSeq: runtime.settleTerminalTopology(args.worktreeId) } } ) + // A window's own sleeping-agent changes, written in each worktree's home; main's push carries them. + ipcMain.handle( + 'session:commit-terminal-sleeping-records', + (_event, changes: { sleep?: unknown; wake?: unknown } | undefined) => { + commitSleepingRecords( + store, + { + sleep: sleepingAgentSessionsByPaneKeySchema.safeParse(changes?.sleep).data ?? {}, + wake: Array.isArray(changes?.wake) + ? changes.wake.filter((paneKey) => typeof paneKey === 'string') + : [] + }, + windowWritableHome + ) + } + ) + + // The window saw a tab's launched agent go; main's row drops it so no save or push brings it back. + ipcMain.handle( + 'session:terminal-clear-launch-agent', + (_event, args: { worktreeId?: unknown; tabId?: unknown } | undefined) => { + if (typeof args?.worktreeId !== 'string' || typeof args.tabId !== 'string') { + return + } + const hostId = windowWritableHome(args.worktreeId) + if (hostId) { + clearLaunchAgent(store, { worktreeId: args.worktreeId, tabId: args.tabId }, hostId) + } + } + ) + + // A tab or pane the window created, held unbound until its spawn binds; the reply names the push. + ipcMain.handle('session:terminal-create-surface', async (_event, args: unknown) => { + const request = parseTerminalSurfaceCreateRequest(args) + if (!request) { + return { status: 'refused', reason: 'invalid_request' } satisfies TerminalSurfaceCreateResult + } + const hostId = windowWritableHome(request.worktreeId) + const result: TerminalSurfaceCreateResult = hostId + ? await store.createTerminalSurface(request, hostId) + : { status: 'refused', reason: 'home_unresolved' } + return { ...result, publishSeq: runtime.settleTerminalTopology(request.worktreeId) } + }) + + // A gesture's geometry; the reply's publishSeq tells the window when main's push holds it. + ipcMain.handle('session:terminal-set-layout', async (_event, args: unknown) => { + const request = parseTerminalLayoutSetRequest(args) + if (!request) { + return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLayoutSetResult + } + // One home per worktree; an unresolved one is unverifiable, so nothing is written. + const hostId = windowWritableHome(request.worktreeId) + const result: TerminalLayoutSetResult = hostId + ? await store.setTerminalTabLayout(request, hostId) + : { status: 'refused', reason: 'home_unresolved' } + return { ...result, publishSeq: runtime.settleTerminalTopology(request.worktreeId) } + }) + + // An adopted live PTY: main records it on its pane, and the push carries it to the window. + ipcMain.handle('session:terminal-bind-leaf', async (_event, args: unknown) => { + const request = parseTerminalLeafBindRequest(args) + if (!request) { + return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLeafBindResult + } + const hostId = windowWritableHome(request.worktreeId) + const bound = hostId !== null && (await bindLeaf(store, request, hostId)) + const result: TerminalLeafBindResult = bound + ? { status: 'bound' } + : { status: 'refused', reason: hostId ? 'not_bound' : 'home_unresolved' } + return { ...result, publishSeq: runtime.settleTerminalTopology(request.worktreeId) } + }) + + // Pull-after-listen: a window subscribes to pushes first, then reads what it missed. + ipcMain.handle('session:get-terminal-topology-slices', () => runtime.getTerminalTopologySlices()) + ipcMain.handle('session:flush', () => { // Why: durable lifecycle RPCs must propagate disk failures instead of // returning success through Store.flush(), which intentionally only logs. @@ -73,7 +180,7 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic void (async () => { try { if (!isFenced(hostId)) { - store.setWorkspaceSession(args, hostId) + setRendererSession(store, args, hostId) } await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) } catch (error) { diff --git a/src/main/ipc/ssh-host-partition-session-export.test.ts b/src/main/ipc/ssh-host-partition-session-export.test.ts index 1ec5167f866b..050d9281b2f6 100644 --- a/src/main/ipc/ssh-host-partition-session-export.test.ts +++ b/src/main/ipc/ssh-host-partition-session-export.test.ts @@ -1,10 +1,8 @@ import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' /** - * What the remote-workspace export publishes when the renderer omits `session`, against the real - * `Store`. + * What main's remote-workspace export publishes after a session write, against the real `Store`. * - * The shipping debounced writer takes that fallback on every session write, and it used to read the - * 'local' blob alone — so an SSH worktree whose tabs the main-process runtime had written to + * The export reads main's persisted session, and it used to read the 'local' blob alone — so an SSH worktree whose tabs the main-process runtime had written to * `ssh:` was projected as an explicit empty tab list. The upload is a * `replace-session` patch, which turns that absence into deletion on the host (#12721, #18173). * @@ -139,24 +137,23 @@ beforeEach(() => { [target, otherTarget].find((candidate) => candidate.id === targetId) }) getActiveMultiplexerMock.mockReset() + const connection = { + request: (method: string, params: Record) => { + if (method === 'workspace.get') { + return Promise.resolve(hostSnapshot) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: params.patch crosses the IPC boundary as unknown; this suite only ever sends a session patch. + const patch = params.patch as { session: RemoteWorkspaceSession } + hostSnapshot = { + ...hostSnapshot, + revision: hostSnapshot.revision + 1, + session: patch.session + } + return Promise.resolve({ ok: true, snapshot: hostSnapshot }) + } + } getActiveMultiplexerMock.mockImplementation((targetId: string) => - targetId === TARGET_ID - ? { - request: (method: string, params: Record) => { - if (method === 'workspace.get') { - return Promise.resolve(hostSnapshot) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: params.patch crosses the IPC boundary as unknown; this suite only ever sends a session patch. - const patch = params.patch as { session: RemoteWorkspaceSession } - hostSnapshot = { - ...hostSnapshot, - revision: hostSnapshot.revision + 1, - session: patch.session - } - return Promise.resolve({ ok: true, snapshot: hostSnapshot }) - } - } - : undefined + targetId === TARGET_ID ? connection : undefined ) }) @@ -174,13 +171,6 @@ function createStrandedStore(): InstanceType { ...getDefaultWorkspaceSession(), tabsByWorktree: { [WORKTREE_ID]: [] } }) - store.setWorkspaceSession( - { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } - }, - SSH_HOST_ID - ) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), @@ -195,11 +185,12 @@ function createStrandedStore(): InstanceType { return store } -async function publishToConnectedTarget(store: InstanceType): Promise { +/** The window agrees with the host, then the runtime writes its tab into `ssh:`. */ +async function publishRuntimeWrite(store: InstanceType): Promise { registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) const get = ipcHandlers.get('remoteWorkspace:get') - const set = ipcHandlers.get('remoteWorkspace:setForConnectedTargets') - if (!get || !set) { + const importPeerTopology = ipcHandlers.get('remoteWorkspace:importPeerTopology') + if (!get || !importPeerTopology) { throw new Error('remote workspace handlers were never registered') } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the remote-workspace get handler answers with this revision/token pair; the IPC return type is unknown. @@ -207,19 +198,28 @@ async function publishToConnectedTarget(store: InstanceType): Prom revision: number hostObservationToken: string } - await set(null, { - // The shipping debounced writer omits `session` and relies on the main-side fallback. - hydratedTargetIds: [TARGET_ID], - expectedRevisionsByTargetId: { [TARGET_ID]: observed.revision }, - expectedHostObservationTokensByTargetId: { [TARGET_ID]: observed.hostObservationToken } + await importPeerTopology(null, { + targetId: TARGET_ID, + ...observed, + outcome: 'synced', + session: {} }) + const revisionBefore = hostSnapshot.revision + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } + }, + SSH_HOST_ID + ) + await vi.waitFor(() => expect(hostSnapshot.revision).toBe(revisionBefore + 1)) } -describe('remoteWorkspace:setForConnectedTargets session fallback', () => { +describe('main export reads the target ssh partition', () => { it('publishes tabs the runtime persisted into the target ssh partition', async () => { const store = createStrandedStore() - await publishToConnectedTarget(store) + await publishRuntimeWrite(store) expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]?.map((tab) => tab.id)).toEqual([ 'tab-runtime' @@ -231,8 +231,41 @@ describe('remoteWorkspace:setForConnectedTargets session fallback', () => { // publishing one for a populated worktree is what destroyed the host's copy on every launch. const store = createStrandedStore() - await publishToConnectedTarget(store) + await publishRuntimeWrite(store) expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]).not.toEqual([]) }) }) + +describe('a window import writes only its target partition', () => { + it('cannot change local or other-host rows, nor roll back a main write not yet mirrored', async () => { + const store = createStrandedStore() + registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) + // Main's own local write, which the window has not received yet. + const localWorktreeId = 'repo-local::/home/me/proj' + const localTab = { ...runtimeAuthoredTab(), id: 'tab-main', worktreeId: localWorktreeId } + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + tabsByWorktree: { + ...store.getWorkspaceSession().tabsByWorktree, + [localWorktreeId]: [localTab] + } + }) + const localBefore = store.getWorkspaceSession() + const otherBefore = store.getWorkspaceSession(OTHER_SSH_HOST_ID) + + await ipcHandlers.get('remoteWorkspace:importPeerTopology')?.(null, { + targetId: TARGET_ID, + revision: 4, + hostObservationToken: 'observation-4', + outcome: 'synced', + session: { tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } } + }) + + expect(store.getWorkspaceSession()).toEqual(localBefore) + expect(store.getWorkspaceSession(OTHER_SSH_HOST_ID)).toEqual(otherBefore) + expect( + store.getWorkspaceSession(SSH_HOST_ID).tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id) + ).toEqual(['tab-runtime']) + }) +}) diff --git a/src/main/legacy-terminal-surface-tombstones-write.test.ts b/src/main/legacy-terminal-surface-tombstones-write.test.ts new file mode 100644 index 000000000000..c0d14357017f --- /dev/null +++ b/src/main/legacy-terminal-surface-tombstones-write.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultPersistedState } from '../shared/constants' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { hasHostAuthoritativeTerminalMembership } from './persistence/terminal-topology/terminal-topology-membership' +import { + createStore, + makeRepo, + makeTerminalTab, + testState, + writeDataFile +} from './persistence-test-harness' +import { TEST_LEAF_1 } from './persistence-session-fixtures' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const WORKTREE = 'repo1::/worktree' +const RUNTIME_HOST = 'runtime:env-1' + +/** A session an older build saved: its close record was left for the next write to apply. */ +function sessionWithUnappliedClose(incarnationId: string): Partial { + return { + tabsByWorktree: { + [WORKTREE]: [makeTerminalTab({ id: 'closed-tab', ptyId: 'pty-1', worktreeId: WORKTREE })] + }, + terminalLayoutsByTabId: { + 'closed-tab': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [`closed-tab:${TEST_LEAF_1}`]: 'incarnation-a' }, + terminalSurfaceTombstonesByPaneKey: { + [`closed-tab:${TEST_LEAF_1}`]: { + worktreeId: WORKTREE, + parentTabId: 'closed-tab', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId, + retiredAt: 42 + } + } + } +} + +async function storeLoadedWith(hostId: string | null, incarnationId: string) { + const persisted = getDefaultPersistedState(testState.dir) + persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })] + const session = { ...persisted.workspaceSession, ...sessionWithUnappliedClose(incarnationId) } + if (hostId) { + persisted.workspaceSessionsByHostId = { [hostId]: session } + } else { + persisted.workspaceSession = session + } + writeDataFile(persisted) + return createStore() +} + +describe('close records an older build left unapplied', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('are applied by the first write, so the closed tab stays closed, then dropped', async () => { + const store = await storeLoadedWith(null, 'incarnation-a') + + store.setWorkspaceSession(store.getWorkspaceSession()) + + const written = store.getWorkspaceSession() + expect(written.tabsByWorktree[WORKTREE]).toEqual([]) + expect(written.terminalLayoutsByTabId['closed-tab']).toBeUndefined() + expect(written.terminalSurfaceTombstonesByPaneKey).toEqual({}) + }) + + it('are dropped when they cannot apply, so a runtime partition is not host-authoritative', async () => { + const store = await storeLoadedWith(RUNTIME_HOST, 'incarnation-b') + + store.setWorkspaceSession(store.getWorkspaceSession(RUNTIME_HOST), RUNTIME_HOST) + + const written = store.getWorkspaceSession(RUNTIME_HOST) + expect(written.tabsByWorktree[WORKTREE]).toHaveLength(1) + expect(written.terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(hasHostAuthoritativeTerminalMembership(written, WORKTREE)).toBe(false) + }) +}) diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index 9c3aad9620f5..ef07f6872bef 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -16,6 +16,10 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../shared/constants' +import { + patchRendererSession, + setRendererSession +} from './persistence/terminal-topology/terminal-renderer-presentation-save' import { isTerminalLeafId } from '../shared/stable-pane-id' import { worktreeWorkspaceKey } from '../shared/workspace-scope' @@ -352,13 +356,14 @@ describe('Store host-partitioned workspace sessions', () => { 'runtime:env-b' ) - store.setWorkspaceSession( + setRendererSession( + store, { ...getDefaultWorkspaceSession(), tabsByWorktree: staleTabs }, 'runtime:env-a' ) - store.patchWorkspaceSession({ tabsByWorktree: staleTabs }, 'runtime:env-a') + patchRendererSession(store, { tabsByWorktree: staleTabs }, 'runtime:env-a') - expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toEqual([]) + expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined() expect( store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.duplicate ).toBe(2) @@ -408,7 +413,7 @@ describe('Store host-partitioned workspace sessions', () => { store.setWorkspaceSession(session, 'runtime:env-b') store.removeWorkspaceSessionStateForWorktree(worktreeId, 'runtime:env-a') - store.setWorkspaceSession(session, 'runtime:env-a') + setRendererSession(store, session, 'runtime:env-a') store.flush() const reloaded = await createStore() diff --git a/src/main/persistence-layout-binding-recovery.test.ts b/src/main/persistence-layout-binding-recovery.test.ts index 7f3998eb58ba..949db8641936 100644 --- a/src/main/persistence-layout-binding-recovery.test.ts +++ b/src/main/persistence-layout-binding-recovery.test.ts @@ -1,16 +1,12 @@ -import { closeTestStores, testState, createStore, makeRepo } from './persistence-test-harness' +import { closeTestStores, testState, createStore } from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { isTerminalLeafId } from '../shared/stable-pane-id' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' -import { - TEST_LEAF_1, - TEST_LEAF_2, - TEST_LEAF_LIVE, - TEST_LEAF_EXPIRED -} from './persistence-session-fixtures' +import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -64,7 +60,7 @@ describe('Store', () => { await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) - it('drops legacy leaf-keyed records from mixed-version writes before binding preservation', async () => { + it('drops legacy leaf-keyed records from a mixed-version window save and keeps main binding', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', @@ -96,7 +92,7 @@ describe('Store', () => { } }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -132,12 +128,7 @@ describe('Store', () => { expect(layout.expandedLeafId).toBeNull() expect(layout.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'daemon-pty' }) expect(layout.buffersByLeafId).toBeUndefined() - expect(layout.scrollbackRefsByLeafId).toEqual({ - [TEST_LEAF_1]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) - }) - const ref = layout.scrollbackRefsByLeafId?.[TEST_LEAF_1] - expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('Current buffer') - expect(layout.titlesByLeafId).toEqual({ [TEST_LEAF_1]: 'Current' }) + expect(layout.titlesByLeafId).toBeUndefined() expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty') }) @@ -213,9 +204,9 @@ describe('Store', () => { expect(leafId).not.toBe(TEST_LEAF_2) }) - // An `expired` lease means reattach gave up, not that the remote shell died. Dropping the - // binding here left the pane unable to re-adopt a process that is still running. - it('restores a cleared SSH binding after a lease expired so the pane can reattach', async () => { + // An `expired` lease means reattach gave up, not that the remote shell died; the pane must + // keep main's binding to re-adopt a process that is still running. + it('keeps the SSH binding after a lease expired when a window save clears it', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', @@ -253,7 +244,7 @@ describe('Store', () => { } }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -288,7 +279,7 @@ describe('Store', () => { }) }) - it('does not restore cleared SSH bindings after a lease was terminated', async () => { + it('a window save still carrying a binding does not undo main terminating its lease', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', @@ -296,7 +287,7 @@ describe('Store', () => { worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, - state: 'terminated' + state: 'attached' }) store.setWorkspaceSession({ activeRepoId: 'r1', @@ -312,7 +303,7 @@ describe('Store', () => { color: null, sortOrder: 0, createdAt: 1, - ptyId: 'remote-pty' + ptyId: 'ssh:ssh-1@@remote-pty' } ] }, @@ -321,12 +312,14 @@ describe('Store', () => { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' } } } }) - store.setWorkspaceSession({ + store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -340,7 +333,7 @@ describe('Store', () => { color: null, sortOrder: 0, createdAt: 1, - ptyId: null + ptyId: 'ssh:ssh-1@@remote-pty' } ] }, @@ -349,7 +342,7 @@ describe('Store', () => { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: {} + ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' } } } }) @@ -358,299 +351,4 @@ describe('Store', () => { expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) - - it('does not let an expired lease for another tab suppress a matching pty id', async () => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty', - worktreeId: 'wt1', - tabId: 'tab-expired', - leafId: TEST_LEAF_EXPIRED, - state: 'expired' - }) - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab-live', - tabsByWorktree: { - wt1: [ - { - id: 'tab-live', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty' - } - ] - }, - terminalLayoutsByTabId: { - 'tab-live': { - root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, - activeLeafId: TEST_LEAF_LIVE, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'remote-pty' } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab-live', - tabsByWorktree: { - wt1: [ - { - id: 'tab-live', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - 'tab-live': { - root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, - activeLeafId: TEST_LEAF_LIVE, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - }) - - const session = store.getWorkspaceSession() - expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') - expect(session.terminalLayoutsByTabId['tab-live'].ptyIdsByLeafId).toEqual({ - [TEST_LEAF_LIVE]: 'remote-pty' - }) - }) - - it('does not let an expired lease for another SSH target suppress the same tab binding', async () => { - const store = await createStore() - store.addRepo(makeRepo({ id: 'repo-live', connectionId: 'ssh-live' })) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-expired', - ptyId: 'remote-pty', - worktreeId: 'repo-live::/wt', - tabId: 'tab-live', - leafId: TEST_LEAF_LIVE, - state: 'expired' - }) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-live', - ptyId: 'remote-pty', - worktreeId: 'repo-live::/wt', - tabId: 'tab-live', - leafId: TEST_LEAF_LIVE, - state: 'detached' - }) - store.setWorkspaceSession({ - activeRepoId: 'repo-live', - activeWorktreeId: 'repo-live::/wt', - activeTabId: 'tab-live', - tabsByWorktree: { - 'repo-live::/wt': [ - { - id: 'tab-live', - worktreeId: 'repo-live::/wt', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty' - } - ] - }, - terminalLayoutsByTabId: { - 'tab-live': { - root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, - activeLeafId: TEST_LEAF_LIVE, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'remote-pty' } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'repo-live', - activeWorktreeId: 'repo-live::/wt', - activeTabId: 'tab-live', - tabsByWorktree: { - 'repo-live::/wt': [ - { - id: 'tab-live', - worktreeId: 'repo-live::/wt', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - 'tab-live': { - root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, - activeLeafId: TEST_LEAF_LIVE, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - }) - - const session = store.getWorkspaceSession() - expect(session.tabsByWorktree['repo-live::/wt'][0].ptyId).toBe('remote-pty') - expect(session.terminalLayoutsByTabId['tab-live'].ptyIdsByLeafId).toEqual({ - [TEST_LEAF_LIVE]: 'remote-pty' - }) - }) - - it('does not treat contextless expired leases as wildcards for contextual bindings', async () => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty', - state: 'expired' - }) - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - }) - - const session = store.getWorkspaceSession() - expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') - expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'remote-pty' - }) - }) - - it('does not treat layout-level leases missing worktree context as contextual matches', async () => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'expired' - }) - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - }) - - expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'remote-pty' - }) - }) }) diff --git a/src/main/persistence-pane-identity-migration.test.ts b/src/main/persistence-pane-identity-migration.test.ts index c39a93636a39..611ac14192ef 100644 --- a/src/main/persistence-pane-identity-migration.test.ts +++ b/src/main/persistence-pane-identity-migration.test.ts @@ -12,6 +12,8 @@ import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { agentHookServer } from './agent-hooks/server' +import { getMigrationUnsupportedPtySnapshot } from './agent-hooks/migration-unsupported-pty-state' +import { getDefaultWorkspaceSession } from '../shared/constants' import { TEST_LEAF_1, @@ -645,4 +647,37 @@ describe('Store', () => { }) ) }) + + it('a save of a legacy numeric pane layout flags no live PTY as needing a restart', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'legacy-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'legacy-pty' } + } + } + }) + + const root = store.getWorkspaceSession().terminalLayoutsByTabId.tab1.root + expect(root?.type === 'leaf' && isTerminalLeafId(root.leafId)).toBe(true) + expect(getMigrationUnsupportedPtySnapshot()).toEqual([]) + }) }) diff --git a/src/main/persistence-pty-binding-reconciliation.test.ts b/src/main/persistence-pty-binding-reconciliation.test.ts index c4fbf61f07b4..013e33711f6b 100644 --- a/src/main/persistence-pty-binding-reconciliation.test.ts +++ b/src/main/persistence-pty-binding-reconciliation.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -148,7 +149,7 @@ describe('Store', () => { } }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -250,7 +251,7 @@ describe('Store', () => { expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' }) ]) - store.setWorkspaceSession({ + setRendererSession(store, { ...admitted, tabsByWorktree: { ...admitted.tabsByWorktree, @@ -278,7 +279,7 @@ describe('Store', () => { [TEST_LEAF_1]: 'Fresh pane title' }) - store.setWorkspaceSession({ + setRendererSession(store, { ...getDefaultWorkspaceSession(), tabsByWorktree: { wt1: [ @@ -424,7 +425,7 @@ describe('Store', () => { [TEST_LEAF_2]: 'pty-split' }) - store.setWorkspaceSession(staleRendererSession, hostId) + setRendererSession(store, staleRendererSession, hostId) expect( store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.ptyIdsByLeafId ).toMatchObject({ diff --git a/src/main/persistence-split-pane-incarnation.test.ts b/src/main/persistence-split-pane-incarnation.test.ts index b628c28384bb..f4374a41750c 100644 --- a/src/main/persistence-split-pane-incarnation.test.ts +++ b/src/main/persistence-split-pane-incarnation.test.ts @@ -10,6 +10,7 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { ProfileStateSqliteAuthority } from './persistence/profile-state/profile-state-sqlite-authority' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../shared/constants' @@ -327,7 +328,7 @@ describe('Store', () => { const rendererSnapshot = structuredClone(store.getWorkspaceSession(hostId)) delete rendererSnapshot.terminalPtyIncarnationsByPaneKey delete rendererSnapshot.terminalTopologyRevisionByRepoId - store.setWorkspaceSession(rendererSnapshot, hostId) + setRendererSession(store, rendererSnapshot, hostId) expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( 'inc-live' @@ -379,4 +380,55 @@ describe('Store', () => { 'inc-stale' ) }) + + it('a fresh spawn into a retired pane clears its tombstone, so the next reattach is accepted', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-old' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-old' } + } + }, + terminalSurfaceTombstonesByPaneKey: { + [paneKey]: { + worktreeId: 'wt1', + parentTabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-old', + incarnationId: 'inc-old', + retiredAt: 1 + } + } + }) + const reattach = { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-new', + mayCreate: false, + mayReviveRetiredSurface: false + } + expect(await store.persistPtyBinding(reattach)).toBe(false) + + expect( + await store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-new', + incarnationId: 'inc-new' + }) + ).toBe(true) + + expect(store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(await store.persistPtyBinding(reattach)).toBe(true) + }) }) diff --git a/src/main/persistence-ssh-lease-tombstone-retention.test.ts b/src/main/persistence-ssh-lease-tombstone-retention.test.ts index 1e52c6ad41c8..1fe5d67dc40a 100644 --- a/src/main/persistence-ssh-lease-tombstone-retention.test.ts +++ b/src/main/persistence-ssh-lease-tombstone-retention.test.ts @@ -66,15 +66,13 @@ describe('operator-closed SSH lease tombstones', () => { return store } - it('keeps the tombstone while a binding the scrub could not reach still names the pty', async () => { + it('retires the tombstone even while a binding the scrub could not reach still names the pty', async () => { const store = await storeWithDetachedPaneBinding() store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') - // `isRestorablePtyBinding` still consults this row to refuse replaying that binding. - expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ - expect.objectContaining({ ptyId: 'remote-pty', state: 'terminated' }) - ]) + // No reader tells a terminated row from an absent one, binding or not. + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) expect(store.getWorkspaceSession().terminalLayoutsByTabId['tab-new'].ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' }) diff --git a/src/main/persistence-ssh-remote-pty-binding-replay.test.ts b/src/main/persistence-ssh-remote-pty-binding-replay.test.ts index 77054a2eafb0..06953176005b 100644 --- a/src/main/persistence-ssh-remote-pty-binding-replay.test.ts +++ b/src/main/persistence-ssh-remote-pty-binding-replay.test.ts @@ -4,8 +4,9 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { TEST_LEAF_1 } from './persistence-session-fixtures' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -59,7 +60,7 @@ describe('Store', () => { await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) - it('retains an SSH host binding when a stale renderer clears its pty map', async () => { + it('keeps the SSH host binding when a stale window save clears its pty map', async () => { const store = await createStore() const hostId = 'ssh:ssh-1' const session = { @@ -98,7 +99,8 @@ describe('Store', () => { leafId: TEST_LEAF_1, state: 'detached' }) - store.setWorkspaceSession( + setRendererSession( + store, { ...session, tabsByWorktree: { @@ -118,56 +120,8 @@ describe('Store', () => { }) }) - it('does not replay a scoped SSH binding from a different host partition', async () => { - const store = await createStore() - const hostId = 'ssh:ssh-1' - const session: WorkspaceSessionState = { - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'ssh:ssh-2@@foreign' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-2@@foreign' } - } - } - } - store.setWorkspaceSession(session, hostId) - store.setWorkspaceSession( - { - ...session, - tabsByWorktree: { - wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }] - }, - terminalLayoutsByTabId: { - tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} } - } - }, - hostId - ) - - const persisted = store.getWorkspaceSession(hostId) - expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull() - expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) - }) - - it('retains a runtime host binding when no death evidence exists', async () => { + // Another server feeds the window its runtime: rows, so the window's save stands there. + it('takes the window rows as sent for a runtime host partition', async () => { const store = await createStore() const hostId = 'runtime:env-1' const session: WorkspaceSessionState = { @@ -198,7 +152,8 @@ describe('Store', () => { } } store.setWorkspaceSession(session, hostId) - store.setWorkspaceSession( + setRendererSession( + store, { ...session, tabsByWorktree: { @@ -210,187 +165,9 @@ describe('Store', () => { }, hostId ) - expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'runtime-pty' - }) + expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) // `expired` is the client admitting it lost its route; the remote shell may still be running, // so the pane keeps the binding it needs to reattach. - it('restores a host binding after its SSH lease expires', async () => { - const store = await createStore() - const hostId = 'ssh:ssh-1' - const session: WorkspaceSessionState = { - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'ssh:ssh-1@@expired' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@expired' } - } - } - } - store.setWorkspaceSession(session, hostId) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'expired', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'expired' - }) - store.setWorkspaceSession( - { - ...session, - tabsByWorktree: { - wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }] - }, - terminalLayoutsByTabId: { - tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} } - } - }, - hostId - ) - const persisted = store.getWorkspaceSession(hostId) - expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBe('ssh:ssh-1@@expired') - expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'ssh:ssh-1@@expired' - }) - }) - - it('does not resurrect a host binding after its SSH lease was terminated', async () => { - const store = await createStore() - const hostId = 'ssh:ssh-1' - const session: WorkspaceSessionState = { - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'ssh:ssh-1@@expired' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@expired' } - } - } - } - store.setWorkspaceSession(session, hostId) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'expired', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'terminated' - }) - store.setWorkspaceSession( - { - ...session, - tabsByWorktree: { - wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }] - }, - terminalLayoutsByTabId: { - tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} } - } - }, - hostId - ) - const persisted = store.getWorkspaceSession(hostId) - expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull() - expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) - }) - - it('retains surviving leaves while ignoring bindings for removed leaves', async () => { - const store = await createStore() - const hostId = 'runtime:env-1' - const session: WorkspaceSessionState = { - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'runtime-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: TEST_LEAF_1 }, - second: { type: 'leaf', leafId: TEST_LEAF_2 } - }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { - [TEST_LEAF_1]: 'runtime-pty-1', - [TEST_LEAF_2]: 'runtime-pty-2' - } - } - } - } - store.setWorkspaceSession(session, hostId) - store.setWorkspaceSession( - { - ...session, - tabsByWorktree: { - wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }] - }, - terminalLayoutsByTabId: { - tab1: { - ...session.terminalLayoutsByTabId.tab1!, - root: { type: 'leaf', leafId: TEST_LEAF_2 }, - activeLeafId: TEST_LEAF_2, - ptyIdsByLeafId: {} - } - } - }, - hostId - ) - const persisted = store.getWorkspaceSession(hostId) - expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull() - expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_2]: 'runtime-pty-2' - }) - }) }) diff --git a/src/main/persistence-ssh-remote-pty-leases.test.ts b/src/main/persistence-ssh-remote-pty-leases.test.ts index ea0d8fe350dd..a1773888ba54 100644 --- a/src/main/persistence-ssh-remote-pty-leases.test.ts +++ b/src/main/persistence-ssh-remote-pty-leases.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os' import { getDefaultPersistedState } from '../shared/constants' import { sshRemotePtyLeaseAllowsReattach } from '../shared/ssh-types' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -101,7 +102,7 @@ describe('Store', () => { await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) - it('merges missing prior layout bindings into partial renderer snapshots', async () => { + it('keeps every binding main holds when a window save names only some', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', @@ -156,7 +157,7 @@ describe('Store', () => { } }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -196,251 +197,6 @@ describe('Store', () => { }) }) - // A partial renderer map is only repaired when a lease says the omitted sibling still belongs to - // this host. `expired` says the client lost its route, not that the sibling died — repair it. - // `terminated` is the operator-close state and must stay refused. - it.each([ - ['expired', { [TEST_LEAF_1]: 'remote-pty-1', [TEST_LEAF_2]: 'remote-pty-2' }], - ['terminated', { [TEST_LEAF_1]: 'remote-pty-1' }] - ] as const)( - 'repairs a partial renderer snapshot for a %s sibling lease only when it is not terminated', - async (siblingState, expected) => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty-1', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'detached' - }) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty-2', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: TEST_LEAF_2, - state: siblingState - }) - const layout = { - root: { - type: 'split' as const, - direction: 'horizontal' as const, - first: { type: 'leaf' as const, leafId: TEST_LEAF_1 }, - second: { type: 'leaf' as const, leafId: TEST_LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null - } - const tabs = { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - } - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: tabs, - terminalLayoutsByTabId: { - tab1: { - ...layout, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1', [TEST_LEAF_2]: 'remote-pty-2' } - } - } - }) - - // The renderer republishes only the leaf it still knows about. - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: tabs, - terminalLayoutsByTabId: { - tab1: { ...layout, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1' } } - } - }) - - expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual( - expected - ) - } - ) - - it('does not restore layout bindings for leaves removed from the incoming layout', async () => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty-1', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'detached' - }) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty-2', - tabId: 'tab1', - leafId: TEST_LEAF_2, - state: 'detached' - }) - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: TEST_LEAF_1 }, - second: { type: 'leaf', leafId: TEST_LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: TEST_LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { - [TEST_LEAF_1]: 'remote-pty-1', - [TEST_LEAF_2]: 'remote-pty-2' - } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1' } - } - } - }) - - expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'remote-pty-1' - }) - }) - - it('does not restore missing layout bindings without a live SSH lease', async () => { - const store = await createStore() - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'local-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: TEST_LEAF_1 }, - second: { type: 'leaf', leafId: TEST_LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: TEST_LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { - [TEST_LEAF_1]: 'local-pty-1', - [TEST_LEAF_2]: 'local-pty-2' - } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'local-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: TEST_LEAF_1 }, - second: { type: 'leaf', leafId: TEST_LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty-1' } - } - } - }) - - expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'local-pty-1' - }) - }) - it('clears workspace bindings before removing SSH remote PTY leases for a target', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ diff --git a/src/main/persistence-host-admitted-terminal-membership.test.ts b/src/main/persistence-terminal-membership-window-save.test.ts similarity index 66% rename from src/main/persistence-host-admitted-terminal-membership.test.ts rename to src/main/persistence-terminal-membership-window-save.test.ts index 83a6898c6671..ded79d938c1c 100644 --- a/src/main/persistence-host-admitted-terminal-membership.test.ts +++ b/src/main/persistence-terminal-membership-window-save.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../shared/constants' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './runtime/mobile-session-terminal-persistence-retirement' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' vi.mock('electron', () => ({ @@ -47,7 +48,7 @@ function persistedTabIds(session: WorkspaceSessionState, worktreeId: string): st return (session.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) } -describe('host-admitted terminal membership survives a stale renderer replay', () => { +describe('terminal membership main holds survives a stale window save', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-host-membership-')) }) @@ -57,7 +58,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( rmSync(testState.dir, { recursive: true, force: true }) }) - it('keeps the first host-admitted tab when the renderer replays its pre-create tab list', async () => { + it('keeps a host-created tab when the window saves its pre-create tab list', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) @@ -67,19 +68,18 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, - ptyId: 'host-pty', - hostAdmittedMembership: true + ptyId: 'host-pty' }) ).toBe(true) expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab') // The renderer's debounced writer flushes a snapshot taken before the create. - store.setWorkspaceSession(rendererSession()) + setRendererSession(store, rendererSession()) expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab') }) - it('keeps a host-admitted tab in a second worktree of the same repo', async () => { + it('keeps a host-created tab in a second worktree of the same repo', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) @@ -87,10 +87,9 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( worktreeId: OTHER_WORKTREE, tabId: 'host-tab-other', leafId: TEST_LEAF_2, - ptyId: 'host-pty-other', - hostAdmittedMembership: true + ptyId: 'host-pty-other' }) - store.setWorkspaceSession(rendererSession()) + setRendererSession(store, rendererSession()) expect(persistedTabIds(store.getWorkspaceSession(), OTHER_WORKTREE)).toContain('host-tab-other') }) @@ -104,23 +103,21 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, - ptyId: 'host-pty', - hostAdmittedMembership: true + ptyId: 'host-pty' }) ).toBe(true) expect(store.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe( true ) - store.setWorkspaceSession(rendererSession()) + setRendererSession(store, rendererSession()) expect(store.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe( true ) }) - // Polarity: without the flag the renderer still owns membership, so a renderer - // spawn racing its own writer must not freeze the tab list. - it('leaves renderer-owned membership alone when the binding is not host-admitted', async () => { + // A window's own spawn is main's too: its save no longer authors membership for any repo. + it('keeps a window-spawned tab when the window saves a tab list from before the spawn', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) @@ -130,15 +127,16 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( leafId: TEST_LEAF_2, ptyId: 'renderer-second-pty' }) - store.setWorkspaceSession(rendererSession()) + setRendererSession(store, rendererSession()) - expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) + expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual([ + 'renderer-tab', + 'renderer-second-tab' + ]) }) - // Closing must still work afterwards. Closes are host-driven: the retirement is - // computed from the store's own session (see stageTerminalSurfaceRetirements), - // which is what outranks the fence this create just raised. - it('still lets the authoritative retirement path close the host-admitted tab', async () => { + // Closes are main's: the retirement is computed from the store's own session. + it('still lets the retirement path close the host-created tab', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) await store.persistPtyBinding({ @@ -146,8 +144,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( tabId: 'host-tab', leafId: TEST_LEAF_2, ptyId: 'host-pty', - incarnationId: 'host-incarnation', - hostAdmittedMembership: true + incarnationId: 'host-incarnation' }) store.setWorkspaceSession( @@ -159,9 +156,38 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( incarnationId: 'host-incarnation' }) ) - // A stale renderer replay must not resurrect it either. - store.setWorkspaceSession(rendererSession()) + // A stale window save must not resurrect it either. + setRendererSession(store, rendererSession()) + + expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) + }) + + // Main's own writers read and write the store directly; nothing rebases their edit away. + it("writes a main writer's unfenced tab removal as is, in a repo main already fenced", async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...rendererSession(), + terminalTopologyRevisionByRepoId: { repo1: 1 } + }) + await store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: 'host-tab', + leafId: TEST_LEAF_2, + ptyId: 'host-pty' + }) + const fenced = store.getWorkspaceSession() + expect(fenced.terminalTopologyRevisionByRepoId?.repo1).toBeGreaterThan(0) + + const { 'host-tab': _removed, ...layouts } = fenced.terminalLayoutsByTabId + store.setWorkspaceSession({ + ...fenced, + tabsByWorktree: { + [WORKTREE]: fenced.tabsByWorktree[WORKTREE].filter((tab) => tab.id !== 'host-tab') + }, + terminalLayoutsByTabId: layouts + }) expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) + expect(store.getWorkspaceSession().terminalLayoutsByTabId).not.toHaveProperty('host-tab') }) }) diff --git a/src/main/persistence-window-save-presentation-fields.test.ts b/src/main/persistence-window-save-presentation-fields.test.ts new file mode 100644 index 000000000000..4f584cb57d16 --- /dev/null +++ b/src/main/persistence-window-save-presentation-fields.test.ts @@ -0,0 +1,219 @@ +import { + closeTestStores, + createStore, + makeTerminalTab, + testState +} from './persistence-test-harness' +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 { getDefaultWorkspaceSession } from '../shared/constants' +import type { Tab } from '../shared/tab-types' +import type { TerminalLayoutSnapshot } from '../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' +import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const WORKTREE = 'repo1::/worktree' + +const mainLayout: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'vertical', + ratio: 0.5, + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-held', [TEST_LEAF_2]: 'pty-held-2' } +} + +function unified(id: string, groupId: string): Tab { + return { + id, + entityId: id, + groupId, + worktreeId: WORKTREE, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +/** What main holds: a split tab and a legacy tab with no layout, in a fenced repo. */ +function mainSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeWorktreeId: WORKTREE, + tabsByWorktree: { + [WORKTREE]: [ + makeTerminalTab({ id: 'held', worktreeId: WORKTREE, ptyId: 'pty-held' }), + makeTerminalTab({ id: 'bare', worktreeId: WORKTREE, ptyId: null, sortOrder: 1 }) + ] + }, + terminalLayoutsByTabId: { held: mainLayout }, + unifiedTabs: { [WORKTREE]: [unified('held', 'group-a'), unified('bare', 'group-a')] }, + tabGroups: { + [WORKTREE]: [ + { id: 'group-a', worktreeId: WORKTREE, activeTabId: 'held', tabOrder: ['held', 'bare'] } + ] + }, + activeTabIdByWorktree: { [WORKTREE]: 'held' }, + terminalPtyIncarnationsByPaneKey: { [`held:${TEST_LEAF_1}`]: 'incarnation-main' }, + terminalTopologyRevisionByRepoId: { repo1: 1 } + } +} + +describe('a window save keeps what the window presents and drops what main does not hold', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-window-save-presentation-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps pin, view mode, generated and AI Vault titles, launch pane fate and chat pane', async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + const presentation = { + isPinned: true, + viewMode: 'chat', + generatedTitle: 'Fix the flaky test', + aiVaultTitle: { agent: 'codex', sessionId: 'session-1', title: 'Vault title' }, + agentLaunchPane: { leafId: TEST_LEAF_1, outcome: { kind: 'unconfirmed' } } + } as const + + setRendererSession(store, { + ...mainSession(), + tabsByWorktree: { + [WORKTREE]: [ + makeTerminalTab({ id: 'held', worktreeId: WORKTREE, ptyId: 'pty-held', ...presentation }), + makeTerminalTab({ id: 'bare', worktreeId: WORKTREE, ptyId: null, sortOrder: 1 }) + ] + }, + terminalLayoutsByTabId: { held: { ...mainLayout, chatLeafId: TEST_LEAF_2 } } + }) + + const saved = store.getWorkspaceSession() + expect(saved.tabsByWorktree[WORKTREE]?.[0]).toMatchObject(presentation) + expect(saved.terminalLayoutsByTabId.held?.chatLeafId).toBe(TEST_LEAF_2) + }) + + it('a window layout for a tab main holds without one is not saved', async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + + setRendererSession(store, { + ...mainSession(), + terminalLayoutsByTabId: { + held: mainLayout, + bare: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: null, + expandedLeafId: null + } + } + }) + + expect(store.getWorkspaceSession().terminalLayoutsByTabId).not.toHaveProperty('bare') + }) + + it("a mixed-version window's incarnations and surface tombstones are not saved", async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + + setRendererSession(store, { + ...mainSession(), + terminalPtyIncarnationsByPaneKey: { [`held:${TEST_LEAF_2}`]: 'incarnation-window' }, + terminalSurfaceTombstonesByPaneKey: { + [`held:${TEST_LEAF_2}`]: { + worktreeId: WORKTREE, + parentTabId: 'held', + leafId: TEST_LEAF_2, + ptyId: 'pty-held-2', + incarnationId: 'incarnation-window', + retiredAt: 2 + } + } + }) + + const saved = store.getWorkspaceSession() + expect(saved.terminalPtyIncarnationsByPaneKey).toEqual({ + [`held:${TEST_LEAF_1}`]: 'incarnation-main' + }) + expect(saved.terminalSurfaceTombstonesByPaneKey ?? {}).toEqual({}) + expect(saved.terminalLayoutsByTabId.held?.root).toEqual(mainLayout.root) + }) + + it('a createTab-era empty layout gets back the panes main holds', async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + + setRendererSession(store, { + ...mainSession(), + terminalLayoutsByTabId: { held: { root: null, activeLeafId: null, expandedLeafId: null } } + }) + + expect(store.getWorkspaceSession().terminalLayoutsByTabId.held?.root).toEqual(mainLayout.root) + }) + + it('a group left holding only a tab main dropped vanishes, with its split slot and recents', async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + + setRendererSession(store, { + ...mainSession(), + unifiedTabs: { + [WORKTREE]: [ + unified('held', 'group-a'), + unified('bare', 'group-a'), + unified('gone', 'group-b') + ] + }, + tabGroups: { + [WORKTREE]: [ + { + id: 'group-a', + worktreeId: WORKTREE, + activeTabId: 'held', + tabOrder: ['held', 'bare'], + recentTabIds: ['gone'] + }, + { id: 'group-b', worktreeId: WORKTREE, activeTabId: 'gone', tabOrder: ['gone'] } + ] + }, + tabGroupLayouts: { + [WORKTREE]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-a' }, + second: { type: 'leaf', groupId: 'group-b' } + } + } + }) + + const saved = store.getWorkspaceSession() + expect(saved.tabGroups?.[WORKTREE]).toEqual([ + { + id: 'group-a', + worktreeId: WORKTREE, + activeTabId: 'held', + tabOrder: ['held', 'bare'], + recentTabIds: [] + } + ]) + expect(saved.tabGroupLayouts?.[WORKTREE]).toEqual({ type: 'leaf', groupId: 'group-a' }) + }) +}) diff --git a/src/main/persistence-worktree-deletion-fencing.test.ts b/src/main/persistence-worktree-deletion-fencing.test.ts index 40a4c50e9f44..e53ffe1eea87 100644 --- a/src/main/persistence-worktree-deletion-fencing.test.ts +++ b/src/main/persistence-worktree-deletion-fencing.test.ts @@ -9,6 +9,7 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' @@ -183,8 +184,8 @@ describe('Store', () => { expect(store.getWorkspaceSession().terminalLayoutsByTabId['old-tab']).toBeUndefined() expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.wt1).toBe(2) - store.setWorkspaceSession(stale) - expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([]) + setRendererSession(store, stale) + expect(store.getWorkspaceSession().tabsByWorktree.wt1).toBeUndefined() await store.persistPtyBinding({ worktreeId: 'wt1', @@ -213,9 +214,9 @@ describe('Store', () => { store.removeWorktreeMeta(worktreeId) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(1) - store.setWorkspaceSession(stale) + setRendererSession(store, stale) - expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toEqual([]) + expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toBeUndefined() }) it('advances deletion authority when only a legacy retirement fence existed', async () => { @@ -258,7 +259,7 @@ describe('Store', () => { terminalTopologyRevisionByRepoId: { repo: 1 } }) - store.setWorkspaceSession({ + setRendererSession(store, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeB]: [makeTerminalTab({ id: 'stale-tab', worktreeId: worktreeB })] @@ -378,7 +379,7 @@ describe('Store', () => { ptyId: 'daemon-pty' }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts index 261162a38258..3cae5287de97 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts @@ -3,7 +3,6 @@ import type { SshRemotePtyLease } from '../../../shared/ssh-types' export type SshPtyLeaseTombstoneRetentionOperations = { state: PersistedState - toComparablePtyId: (targetId: string, ptyId: string) => string } /** A routing tombstone with nothing left to route: the operator closed this PTY and no stop is @@ -17,54 +16,15 @@ function isRetiredRoutingTombstone(lease: SshRemotePtyLease, targetId: string): ) } -/** Every stored-form relay pty id some persisted pane binding still names for this target. - * - * Reads all partitions, not only the two `clearSshRemotePtyBindingsForLeases` scrubs: this answer - * authorizes a delete, so a partition left unscanned would be a binding whose tombstone we dropped. - */ -function boundRelayPtyIds( - operations: SshPtyLeaseTombstoneRetentionOperations, - targetId: string -): Set { - const bound = new Set() - const sessions = [ - operations.state.workspaceSession, - ...Object.values(operations.state.workspaceSessionsByHostId ?? {}) - ] - for (const session of sessions) { - if (!session) { - continue - } - for (const tabs of Object.values(session.tabsByWorktree ?? {})) { - for (const tab of tabs) { - if (tab.ptyId) { - bound.add(operations.toComparablePtyId(targetId, tab.ptyId)) - } - } - } - for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) { - for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { - bound.add(operations.toComparablePtyId(targetId, ptyId)) - } - } - } - return bound -} - /** - * Deletes the `terminated` rows nothing can reach, bounding an array that otherwise only grew. + * Deletes the `terminated` rows no reader needs, bounding an array that otherwise only grew. * - * `terminated` is written with a binding scrub in the same call, so once no persisted binding names - * the id the row answers no question any reader asks. Reattach refuses it + * The row answers no question any reader asks. Reattach refuses it * (`sshRemotePtyLeaseAllowsReattach`), pane recovery matches on `expired` only, the orphan sweep * already classes it neither routed nor expired, and `ssh:reset` / `ssh:terminateSessions` skip it - * outright — every one of those behaves identically on an absent row. The one reader that can still - * observe it is `isRestorablePtyBinding`, and only through a binding whose pty id matches, which is - * exactly what the reachability test rules out. A `pendingKill` is an undelivered stop, so those - * rows stay until the replay retires them. - * - * The reachability test is not redundant with the scrub: a lease freezes its `tabId`, so a pane - * broken out into a new tab leaves a binding the scrub's tab-qualified match no longer reaches. + * outright — every one of those behaves identically on an absent row, whether or not a binding + * still names the pty. A `pendingKill` is an undelivered stop, so those rows stay until the replay + * retires them. * * Does not re-arm the local-worktree-metadata prune gate: a `terminated` lease no longer counts as * a persisted workspace owner, so dropping one cannot make any metadata row more removable. @@ -74,13 +34,7 @@ export function pruneRetiredSshRemotePtyLeaseTombstones( targetId: string ): boolean { const leases = operations.state.sshRemotePtyLeases ?? [] - if (!leases.some((lease) => isRetiredRoutingTombstone(lease, targetId))) { - return false - } - const bound = boundRelayPtyIds(operations, targetId) - const retained = leases.filter( - (lease) => !isRetiredRoutingTombstone(lease, targetId) || bound.has(lease.ptyId) - ) + const retained = leases.filter((lease) => !isRetiredRoutingTombstone(lease, targetId)) if (retained.length === leases.length) { return false } diff --git a/src/main/persistence/loading-store/persisted-state-redundancy.test.ts b/src/main/persistence/loading-store/persisted-state-redundancy.test.ts index 8b9414f4fb8d..9f0fe2dc6c0d 100644 --- a/src/main/persistence/loading-store/persisted-state-redundancy.test.ts +++ b/src/main/persistence/loading-store/persisted-state-redundancy.test.ts @@ -248,4 +248,15 @@ describe('persisted-state redundancy', () => { ) } }) + + it("a host partition write does not keep a copy of local's browser history", () => { + const store = openStore(tempDataFile()) + const history = browserHistory() + store.setWorkspaceSession(session({ browserUrlHistory: history })) + + store.setWorkspaceSession(session({ browserUrlHistory: history }), 'runtime:env-0') + + expect(store.getWorkspaceSession('runtime:env-0')).not.toHaveProperty('browserUrlHistory') + expect(store.getWorkspaceSession().browserUrlHistory).toEqual(history) + }) }) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 216cd4acafcf..bcc62f2d4b22 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -1,5 +1,9 @@ import { isDeepStrictEqual } from 'node:util' -import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { rollbackFailedPtyBinding } from './pty-binding-write-rollback' @@ -22,7 +26,12 @@ import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../../shared/terminal-leaf-move' -import { moveLeaf } from '../terminal-topology/terminal-topology-commit' +import type { + TerminalLayoutSetRequest, + TerminalLayoutSetResult +} from '../../../shared/terminal-layout-set' +import type { TerminalSurfaceCreateRequest } from '../../../shared/terminal-surface-create' +import { createLeafOrTab, moveLeaf, setLayout } from '../terminal-topology/terminal-topology-commit' import { findTerminalBindingConflict } from '../terminal-topology/terminal-owner-invariants' type PtyBindingPersistenceOperationsRuntime = Pick< @@ -46,8 +55,6 @@ export type PersistPtyBindingArgs = { startupCwd?: string expectedBinding?: { ptyId: string; incarnationId?: string } expectedSourceBinding?: PtyBindingSourceExpectation - /** Set by host-initiated creates, which have no renderer session writer behind them. */ - hostAdmittedMembership?: boolean /** * Defaults true, which is what `pty:spawn` needs — it can beat the debounced layout writer * and must be able to mint the surface it is binding. A reattach is the opposite: the pane @@ -59,7 +66,7 @@ export type PersistPtyBindingArgs = { mayReviveRetiredSurface?: boolean /** Span metadata only; see `PtyBindingOrigin`. The write path never reads it. */ origin?: PtyBindingOrigin - /** Where a new leaf goes. Report-only for now: the span records whether it names today's tab. */ + /** Where a new leaf goes; applied only when it names the tab's current shape, see `applyPtyBinding`. */ placement?: TerminalPanePlacement } @@ -164,20 +171,15 @@ export class PtyBindingPersistenceOperations { outcome = 'refused' return { value: false, persist: false } } - // Report-only: a malformed session must not fail the binding it is reporting on. - try { - span.setPlacement( - terminalPanePlacementAgreement( - args.placement, - session, - bindingWorktreeId, - args.tabId, - args.leafId - ) + span.setPlacement( + terminalPanePlacementAgreement( + args.placement, + session, + bindingWorktreeId, + args.tabId, + args.leafId ) - } catch { - span.setPlacement('check_threw') - } + ) const verdict = evaluatePtyBindingFastLane( args, session, @@ -213,19 +215,42 @@ export class PtyBindingPersistenceOperations { } /** - * Detach-to-new-tab, committed before the renderer mounts the target tab (STA-9259). It lives on - * the binding domain only for its runtime and partition access; the commit module owns the write. + * Detach-to-new-tab in the worktree's home partition, committed before the renderer mounts the + * target tab (STA-9259). It lives on the binding domain only for its runtime and partition + * access; the commit module owns the write. */ - moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise { - const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] - return runtime.runDurableMutation( - moveLeaf(request, { - state: runtime.state, - hostIds: () => sessions.getWorkspaceSessionHostIds(), - getSession: (hostId) => sessions.getWorkspaceSession(hostId), - markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) - }) - ) + moveTerminalLeafToNewTab( + request: TerminalLeafMoveRequest, + hostId: ExecutionHostId + ): Promise { + const { runtime } = this[ptyBindingPersistenceOperationsContext] + return runtime.runDurableMutation(moveLeaf(request, hostId, topologyCommitContext(this))) + } + + /** A tab or pane the window created, recorded unbound in the worktree's home partition. */ + createTerminalSurface(request: TerminalSurfaceCreateRequest, hostId: ExecutionHostId) { + const { runtime } = this[ptyBindingPersistenceOperationsContext] + return runtime.runDurableMutation(createLeafOrTab(request, hostId, topologyCommitContext(this))) + } + + /** A user's divider, direction or pane-order edit, written to the worktree's home partition. */ + setTerminalTabLayout( + request: TerminalLayoutSetRequest, + hostId: ExecutionHostId + ): Promise { + const { runtime } = this[ptyBindingPersistenceOperationsContext] + return runtime.runDurableMutation(setLayout(request, hostId, topologyCommitContext(this))) + } +} + +function topologyCommitContext( + owner: PtyBindingPersistenceOperations +): Parameters[2] { + const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] + return { + state: runtime.state, + getSession: (hostId) => sessions.getWorkspaceSession(hostId), + markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) } } diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index ce412cf4633d..86379aa9c928 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -5,23 +5,8 @@ import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placem import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id' import { firstLayoutLeafId } from '../restoring-sessions/terminal-layout-normalization' import { fixture } from './profile-state-delayed-authority-fixture' -import type * as AgreementModule from '../terminal-topology/terminal-pane-placement-agreement' import type { PersistPtyBindingArgs } from './pty-binding-persistence' -const agreementCheck = vi.hoisted(() => ({ throws: false })) -vi.mock('../terminal-topology/terminal-pane-placement-agreement', async (importOriginal) => { - const actual = await importOriginal() - return { - terminalPanePlacementAgreement: ( - ...args: Parameters - ) => { - if (agreementCheck.throws) { - throw new Error('malformed session') - } - return actual.terminalPanePlacementAgreement(...args) - } - } -}) vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({ nth_repo_added: 2 }) @@ -39,10 +24,11 @@ const NOW = 1_800_000_000_000 const NEW_TAB: TerminalPanePlacement = { kind: 'new-tab' } const ROOT: TerminalPanePlacement = { kind: 'root' } +// Vertical after a lone root leaf is the shape today's graft guesses, so placement adds nothing. const split = (parentLeafId: string): TerminalPanePlacement => ({ kind: 'split', parentLeafId, - direction: 'horizontal' + direction: 'vertical' }) type Scenario = { @@ -64,8 +50,7 @@ const SCENARIOS: Scenario[] = [ leafId: TEST_LEAF_1, ptyId: 'pty-new', incarnationId: 'inc-new', - startupCwd: '/fixture/local/sub', - hostAdmittedMembership: true + startupCwd: '/fixture/local/sub' }, placements: (leaf) => [ [NEW_TAB, 'agrees'], @@ -110,8 +95,7 @@ const SCENARIOS: Scenario[] = [ worktreeId: FOLDER_WORKTREE, tabId: 'tab-folder', leafId: TEST_LEAF_1, - ptyId: 'pty-folder', - hostAdmittedMembership: true + ptyId: 'pty-folder' }, placements: () => [ [NEW_TAB, 'agrees'], @@ -175,7 +159,6 @@ beforeEach(() => { }) afterEach(() => { - agreementCheck.throws = false vi.useRealTimers() _resetTracerForTests() }) @@ -227,7 +210,7 @@ async function bindAndSave( } } -describe('placement on the binding write is inert', () => { +describe('placement that names today’s shape, or falls back, writes today’s state', () => { for (const scenario of SCENARIOS) { it(`${scenario.name}: memory and saved state match a write without placement`, async () => { const baseline = await bindAndSave(scenario, undefined) @@ -245,13 +228,47 @@ describe('placement on the binding write is inert', () => { }) } - it('a throwing agreement check leaves the result and state as without placement', async () => { - const scenario = SCENARIOS[0] - const baseline = await bindAndSave(scenario, undefined) - agreementCheck.throws = true - const placed = await bindAndSave(scenario, () => NEW_TAB) - expect(placementAttributes).toEqual(['check_threw']) - expect(placed).toEqual(baseline) + it('a placed split and a placed row reach the saved ssh partition', async () => { + vi.setSystemTime(NOW) + const { store, readState } = await fixture() + const parent = firstLayoutLeafId( + store.getWorkspaceSession(SSH_HOST).terminalLayoutsByTabId['tab-remote']?.root ?? null + ) + if (!parent) { + throw new Error('fixture lost its existing tab') + } + await store.persistPtyBinding( + { + worktreeId: REMOTE_WORKTREE, + tabId: 'tab-remote', + leafId: TEST_LEAF_2, + ptyId: 'pty-ssh-split', + placement: { kind: 'split', parentLeafId: parent, direction: 'horizontal', ratio: 0.3 } + }, + SSH_HOST + ) + await store.persistPtyBinding( + { + worktreeId: REMOTE_WORKTREE, + tabId: 'tab-ssh-new', + leafId: TEST_LEAF_1, + ptyId: 'pty-ssh-new', + placement: { kind: 'new-tab', row: { title: 'logs', color: '#f97316' } } + }, + SSH_HOST + ) + await store.flushPendingOrThrowAsync() + const saved = readState().workspaceSessionsByHostId?.[SSH_HOST] + expect(saved?.terminalLayoutsByTabId['tab-remote']?.root).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: parent }, + second: { type: 'leaf', leafId: TEST_LEAF_2 }, + ratio: 0.3 + }) + expect( + saved?.tabsByWorktree[REMOTE_WORKTREE]?.find(({ id }) => id === 'tab-ssh-new') + ).toMatchObject({ title: 'logs', color: '#f97316', pendingActivationSpawn: true }) }) it('a tombstoned pane is still refused with placement', async () => { diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts index 9de369b76829..53732696fa41 100644 --- a/src/main/persistence/loading-store/pty-binding-refusals.ts +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -1,7 +1,7 @@ import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { TerminalSessionPartition } from '../terminal-topology/terminal-owner-invariants' +import type { TerminalSessionPartition } from '../terminal-topology/terminal-topology-membership' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' import type { PtyBindingSourceExpectation } from './store' diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index 3024543505a6..7ab0cd567a3b 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -1,11 +1,9 @@ import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' -import { - cloneLayoutNode, - layoutContainsLeafId -} from '../restoring-sessions/terminal-layout-normalization' -import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { terminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' +import { placeTerminalPane } from '../terminal-topology/terminal-pane-placement-apply' +import { startsOrAdvancesTerminalFence } from '../terminal-topology/terminal-topology-membership' import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' import type { PersistPtyBindingArgs } from './pty-binding-persistence' @@ -15,30 +13,19 @@ export function applyPtyBinding( bindingWorktreeId: string, paneKey: string ): void { + // Read before any write: only a placement that names this tab's current shape is applied. + const placement = + terminalPanePlacementAgreement( + args.placement, + session, + bindingWorktreeId, + args.tabId, + args.leafId + ) === 'agrees' + ? args.placement + : undefined const reconciledIncarnation = args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - let hostAdmittedTabCreated = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } - } if (args.incarnationId) { session.terminalPtyIncarnationsByPaneKey = { ...session.terminalPtyIncarnationsByPaneKey, @@ -51,8 +38,19 @@ export function applyPtyBinding( delete session.terminalSurfaceTombstonesByPaneKey[paneKey] } } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) + const tabExisted = session.tabsByWorktree[bindingWorktreeId]?.some((t) => t.id === args.tabId) + // A spawn can beat the window's creation commit, so the binding can create its pane too. + const terminalMembershipChanged = placeTerminalPane( + session, + { + worktreeId: bindingWorktreeId, + tabId: args.tabId, + leafId: args.leafId, + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}) + }, + placement + ) + const tab = session.tabsByWorktree[bindingWorktreeId]?.find((t) => t.id === args.tabId) if (tab) { tab.ptyId = tabRowPtyIdAfterLeafBinding( tab, @@ -60,27 +58,20 @@ export function applyPtyBinding( args.leafId, args.ptyId ) - } else { - terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + if ( + reconciledIncarnation || + (terminalMembershipChanged && + startsOrAdvancesTerminalFence( + session, + bindingWorktreeId, + args.expectedSourceBinding !== undefined || !tabExisted + )) + ) { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 } } // Why: host-initiated persist snapshots used to omit this write-once guard, so every launch or reattach treated the worktree as never having default terminals applied. @@ -98,49 +89,9 @@ export function applyPtyBinding( bindingWorktreeId ] } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - return - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + const layout = isTerminalLeafId(args.leafId) && session.terminalLayoutsByTabId[args.tabId] if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } - } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null - } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } - } - } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [args.leafId]: args.ptyId } } - advanceTopologyFence() } diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index 74c34ec1a68e..93a59e00227d 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -1,6 +1,6 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { applyLegacyTerminalSurfaceTombstones } from '../../runtime/mobile-session-terminal-persistence-retirement' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, @@ -12,7 +12,6 @@ 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 { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import { @@ -23,11 +22,6 @@ import { import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' import { scheduleSave } from './write-scheduling' -import { - preserveMissingWorkspaceSessionTerminalBindings, - sshTargetIdForWorkspaceSessionHost -} from './workspace-session-terminal-binding-replay' -import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' type SessionHostPartitionOperationsRuntime = Pick< StoreRuntimeState, @@ -38,7 +32,6 @@ const sessionHostPartitionOperationsContext = Symbol('SessionHostPartitionOperat type SessionHostPartitionOperationsContext = { runtime: SessionHostPartitionOperationsRuntime scheduling: WriteSchedulingOperations - bindingRecovery: TerminalBindingRecoveryOperations } export class SessionHostPartitionOperations { @@ -46,10 +39,9 @@ export class SessionHostPartitionOperations { constructor( runtime: SessionHostPartitionOperationsRuntime, - scheduling: WriteSchedulingOperations, - bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations ) { - this[sessionHostPartitionOperationsContext] = { runtime, scheduling, bindingRecovery } + this[sessionHostPartitionOperationsContext] = { runtime, scheduling } } getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { @@ -193,22 +185,7 @@ export function setHostWorkspaceSession( hostId: ExecutionHostId, session: WorkspaceSessionState ): void { - const prior = - owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] - // Why here and not at the callers: the before-unload stage path writes the renderer's payload - // straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows. - session = preserveRuntimeAuthoredWorkspaceSessionFields(session, prior) - // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. - session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) - session = preserveMissingWorkspaceSessionTerminalBindings( - session, - prior, - owner[sessionHostPartitionOperationsContext].bindingRecovery, - { - targetIdForWorktree: sshTargetIdForWorkspaceSessionHost(hostId), - executionHostId: hostId - } - ) + session = applyLegacyTerminalSurfaceTombstones(session) // Why here too: the load-side drop only survives until the next full snapshot write. A renderer // or runtime payload that still carries local's globals would re-inject them into this partition. const pruned = withoutRedundantGlobalFields( diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 13174ef18b5a..85877a2ab603 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -11,7 +11,6 @@ import { workspaceSessionPatchNeedsFullNormalization } from './terminal-session- import { setLocalWorkspaceSession } from './workspace-session-snapshot-publication' import type { StoreRuntimeState } from './store-runtime-state' import type { SessionHostPartitionOperations } from './session-host-partitions' -import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' import type { WriteSchedulingOperations } from './write-scheduling' import { resolveHostId, @@ -36,7 +35,6 @@ const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') type SessionSnapshotOperationsContext = { runtime: SessionSnapshotOperationsRuntime sessions: SessionHostPartitionOperations - bindingRecovery: TerminalBindingRecoveryOperations scheduling: WriteSchedulingOperations } @@ -46,10 +44,9 @@ export class SessionSnapshotOperations { constructor( runtime: SessionSnapshotOperationsRuntime, sessions: SessionHostPartitionOperations, - bindingRecovery: TerminalBindingRecoveryOperations, scheduling: WriteSchedulingOperations ) { - this[sessionSnapshotOperationsContext] = { runtime, sessions, bindingRecovery, scheduling } + this[sessionSnapshotOperationsContext] = { runtime, sessions, scheduling } } setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { @@ -57,7 +54,7 @@ export class SessionSnapshotOperations { const { runtime } = this[sessionSnapshotOperationsContext] if (runtime.durableMutationPhase === 'rollback') { this.assertSnapshotAdmission(true) - // The fieldwise rollback already preserves newer edits; renderer rebasing would undo it. + // The fieldwise rollback already holds the newer edits; it is published as is. this.publishSession(session, resolved) return } diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 7188b4efb664..f32f14e94ad9 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -39,9 +39,9 @@ import { type SshPtyBindingCleanupOperations } from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' +import { toComparableRelaySshPtyId, toRelaySshPtyId } from '../../providers/ssh-pty-id' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteFlushBarrierOperations } from './write-flush-barriers' -import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' import type { WriteSchedulingOperations } from './write-scheduling' import { scheduleSave } from './write-scheduling' import { @@ -56,7 +56,6 @@ const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') type SshLeaseRecoveryOperationsContext = { runtime: SshLeaseRecoveryOperationsRuntime flushBarriers: WriteFlushBarrierOperations - bindingRecovery: TerminalBindingRecoveryOperations scheduling: WriteSchedulingOperations } @@ -66,15 +65,9 @@ export class SshLeaseRecoveryOperations { constructor( runtime: SshLeaseRecoveryOperationsRuntime, flushBarriers: WriteFlushBarrierOperations, - bindingRecovery: TerminalBindingRecoveryOperations, scheduling: WriteSchedulingOperations ) { - this[sshLeaseRecoveryOperationsContext] = { - runtime, - flushBarriers, - bindingRecovery, - scheduling - } + this[sshLeaseRecoveryOperationsContext] = { runtime, flushBarriers, scheduling } } getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { @@ -224,11 +217,7 @@ export function getSshPtyBindingCleanupOperations( ): SshPtyBindingCleanupOperations { return { state: owner[sshLeaseRecoveryOperationsContext].runtime.state, - toComparablePtyId: (targetId, ptyId) => - owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseComparison( - targetId, - ptyId - ), + toComparablePtyId: toComparableRelaySshPtyId, scheduleSave: () => scheduleSave(owner[sshLeaseRecoveryOperationsContext].scheduling) } } @@ -236,16 +225,8 @@ export function getSshPtyBindingCleanupOperations( export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): SshPtyLeaseOperations { return { state: owner[sshLeaseRecoveryOperationsContext].runtime.state, - toStoredPtyId: (targetId, ptyId) => - owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseStorage( - targetId, - ptyId - ), - toComparablePtyId: (targetId, ptyId) => - owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseComparison( - targetId, - ptyId - ), + toStoredPtyId: toRelaySshPtyId, + toComparablePtyId: toComparableRelaySshPtyId, clearBindingsForTarget: (targetId) => clearSshRemotePtyBindingsForTargetOperation( getSshPtyBindingCleanupOperations(owner), diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index cf3063ad37c8..70c336f40d6f 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -21,7 +21,6 @@ import { RepoLifecycleOperations, installRepoLifecycleOperationsContext } from './repo-lifecycle-operations' -import { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' import { SessionHostPartitionOperations, installSessionHostPartitionOperationsContext @@ -101,7 +100,6 @@ export type StoreDomains = { flushBarriers: WriteFlushBarrierOperations preferences: ProfilePreferences repos: RepoLifecycleOperations - bindingRecovery: TerminalBindingRecoveryOperations sessions: SessionHostPartitionOperations sessionSnapshots: SessionSnapshotOperations metadata: MetadataLineageOperations @@ -169,14 +167,8 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const flushBarriers = new WriteFlushBarrierOperations(runtime, writes) const preferences = new ProfilePreferences(runtime, scheduling) const repos = new RepoLifecycleOperations(runtime, scheduling) - const bindingRecovery = new TerminalBindingRecoveryOperations(runtime) - const sessions = new SessionHostPartitionOperations(runtime, scheduling, bindingRecovery) - const sessionSnapshots = new SessionSnapshotOperations( - runtime, - sessions, - bindingRecovery, - scheduling - ) + const sessions = new SessionHostPartitionOperations(runtime, scheduling) + const sessionSnapshots = new SessionSnapshotOperations(runtime, sessions, scheduling) const metadata = new MetadataLineageOperations(runtime, scheduling, sessions) const projects = new ProjectCollectionOperations(runtime, repos, scheduling, metadata) const automations = new AutomationPersistence(runtime, flushBarriers, preferences) @@ -185,12 +177,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) const sshProfiles = new SshProfileOperations(runtime, scheduling, repos) const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) - const sshLeases = new SshLeaseRecoveryOperations( - runtime, - flushBarriers, - bindingRecovery, - scheduling - ) + const sshLeases = new SshLeaseRecoveryOperations(runtime, flushBarriers, scheduling) const orcadCatalogImports = new OrcadCatalogImportPersistence(runtime, repos, scheduling) return { adaptation, @@ -202,7 +189,6 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { flushBarriers, preferences, repos, - bindingRecovery, sessions, sessionSnapshots, metadata, diff --git a/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts b/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts index 1cc673e52b47..81eb605f9806 100644 --- a/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts +++ b/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts @@ -1,11 +1,8 @@ import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' /** - * Drives the real `Store`, not the helper and not a fake. - * - * `preserveRuntimeAuthoredWorkspaceSessionFields` is only worth anything where the shipping writers - * call it, and a mutation that unwires it at the call site survives every test that drives the - * helper directly. Both desktop write paths belong here: the ordinary session write and the - * before-unload stage, which is the one the desktop actually takes when the user quits. + * Drives the real `Store` through the window's save entries, not a fake. Both desktop write paths + * belong here: the ordinary session write and the before-unload stage, which is the one the desktop + * actually takes when the user quits. */ import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -33,6 +30,8 @@ vi.mock('electron', () => ({ })) const { Store } = await import('./store') +const { setRendererSession, stageRendererSessionBeforeUnload } = + await import('../terminal-topology/terminal-renderer-presentation-save') const HOST_ID = 'ssh:user@host' const WT = 'repo-1::/tmp/worktree-a' @@ -94,7 +93,7 @@ describe('Store keeps runtime-authored session fields across desktop writes', () const store = createStore() seedRuntimeRows(store) - store.setWorkspaceSession(rendererSession('after-write')) + setRendererSession(store, rendererSession('after-write')) expect(store.getWorkspaceSession().clientHostedBrowserPagesByWorktree).toEqual({ [WT]: [page()] @@ -108,7 +107,7 @@ describe('Store keeps runtime-authored session fields across desktop writes', () // The beforeunload chain (use-app-session-persistence -> stageBeforeUnloadSync -> // renderer-shutdown-checkpoint) lands here, and it is the last write before the process exits. - store.stageWorkspaceSessionBeforeUnload(rendererSession('before-unload')) + stageRendererSessionBeforeUnload(store, rendererSession('before-unload')) expect(store.getWorkspaceSession().clientHostedBrowserPagesByWorktree).toEqual({ [WT]: [page()] @@ -119,12 +118,12 @@ describe('Store keeps runtime-authored session fields across desktop writes', () const store = createStore() seedRuntimeRows(store, HOST_ID) - store.setWorkspaceSession(rendererSession('after-write'), HOST_ID) + setRendererSession(store, rendererSession('after-write'), HOST_ID) expect(store.getWorkspaceSession(HOST_ID).clientHostedBrowserPagesByWorktree).toEqual({ [WT]: [page()] }) - store.stageWorkspaceSessionBeforeUnload(rendererSession('before-unload'), HOST_ID) + stageRendererSessionBeforeUnload(store, rendererSession('before-unload'), HOST_ID) expect(store.getWorkspaceSession(HOST_ID).clientHostedBrowserPagesByWorktree).toEqual({ [WT]: [page()] }) @@ -195,8 +194,8 @@ describe('a host partition written from a per-host renderer snapshot', () => { activeWorktreeId: null, unifiedTabs: {} } as unknown as WorkspaceSessionState - store.setWorkspaceSession(slice, 'runtime:env-1') - store.stageWorkspaceSessionBeforeUnload(slice, HOST_ID) + setRendererSession(store, slice, 'runtime:env-1') + stageRendererSessionBeforeUnload(store, slice, HOST_ID) for (const hostId of ['runtime:env-1', HOST_ID]) { expect(store.getWorkspaceSession(hostId)).toMatchObject({ tabsByWorktree: {}, diff --git a/src/main/persistence/loading-store/terminal-binding-recovery.ts b/src/main/persistence/loading-store/terminal-binding-recovery.ts deleted file mode 100644 index e3b4a99e61b5..000000000000 --- a/src/main/persistence/loading-store/terminal-binding-recovery.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' -import type { SshRemotePtyLease } from '../../../shared/ssh-types' -import { toComparableRelaySshPtyId, toRelaySshPtyId } from '../../providers/ssh-pty-id' -import { isTerminalLeafId } from '../../../shared/stable-pane-id' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' - -import type { StoreRuntimeState } from './store-runtime-state' - -type TerminalBindingRecoveryOperationsRuntime = Pick - -/** - * `terminated` is the only lease state that withdraws a pane binding. It is the operator-close - * state, and the one written after a host-acknowledged stop. - * - * `expired` is deliberately not death: every writer of it records that the CLIENT lost its route — - * a superseded sibling, a recycled relay id, a persistPtyBinding refusal, a failed reattach, a - * relay reset — and `docs/reference/ssh-execution-boundary.md` grades all of those `unverifiable`. - * Refusing the binding there strands a remote shell that is still running behind a pane that can no - * longer reach it. Keeping it authorizes a reattach ATTEMPT, never a respawn: when the shell really - * is gone, `attachStablePaneOwner` retires the binding on the relay's own absence answer and falls - * through to a fresh spawn. - */ -function sshRemotePtyLeaseWithdrawsBinding(lease: SshRemotePtyLease): boolean { - return lease.state === 'terminated' -} - -export class TerminalBindingRecoveryOperations { - constructor(private readonly runtime: TerminalBindingRecoveryOperationsRuntime) {} - - getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { - const leafIds = new Set() - const visit = (node: TerminalPaneLayoutNode | null): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - if (isTerminalLeafId(node.leafId)) { - leafIds.add(node.leafId) - } - return - } - visit(node.first) - visit(node.second) - } - visit(root) - return leafIds - } - - isRestorablePtyBinding(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - const leases = this.runtime.state.sshRemotePtyLeases?.filter((entry) => - this.sshRemotePtyLeaseMatchesBinding(entry, binding) - ) - return !leases?.some(sshRemotePtyLeaseWithdrawsBinding) - } - - getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { - return toComparableRelaySshPtyId(targetId, ptyId) - } - - getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { - return toRelaySshPtyId(targetId, ptyId) - } - - sshRemotePtyLeaseMatchesBinding( - lease: SshRemotePtyLease, - binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - } - ): boolean { - const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) - if (lease.ptyId !== bindingPtyId) { - return false - } - // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. - return ( - (binding.targetId === undefined || - binding.targetId === null || - lease.targetId === binding.targetId) && - (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && - (binding.tabId === undefined || lease.tabId === binding.tabId) && - (binding.leafId === undefined || lease.leafId === binding.leafId) - ) - } - - hasRestorableSshRemotePtyLease(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - return ( - this.runtime.state.sshRemotePtyLeases?.some( - (lease) => - this.sshRemotePtyLeaseMatchesBinding(lease, binding) && - !sshRemotePtyLeaseWithdrawsBinding(lease) - ) ?? false - ) - } - - getConnectionIdForWorktree(worktreeId: string): string | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return this.runtime.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null - } -} diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 178f1d833ff7..1716cb687478 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -1,6 +1,6 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { applyLegacyTerminalSurfaceTombstones } from '../../runtime/mobile-session-terminal-persistence-retirement' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { setMigrationUnsupportedPty } from '../../agent-hooks/migration-unsupported-pty-state' import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' @@ -10,7 +10,6 @@ import { deleteRemovedTerminalScrollbackSnapshotsAsync, migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync } from '../../terminal-scrollback-snapshot-async-migration' -import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' import { normalizeWorkspaceSessionPaneIdentities, @@ -21,7 +20,6 @@ import { type WorkspaceSessionPaneIdentityRemap } from '../restoring-sessions/workspace-pane-normalization' import { deleteRemovedTerminalScrollbackSnapshots } from './terminal-session-cleanup' -import { preserveMissingWorkspaceSessionTerminalBindings } from './workspace-session-terminal-binding-replay' import { getSessionSnapshotOperationsContext, type SessionSnapshotOperations @@ -35,15 +33,13 @@ export function setLocalWorkspaceSession( ): void { const context = getSessionSnapshotOperationsContext(owner) const prior = context.runtime.state.workspaceSession - // Why here and not at the callers: the before-unload stage path writes the renderer's payload - // straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows. - session = preserveRuntimeAuthoredWorkspaceSessionFields(session, prior) - session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) session = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + pruneLocalTerminalScrollbackBuffers( + applyLegacyTerminalSurfaceTombstones(session), + context.runtime.state.repos + ) ) - // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. const normalized = normalizeWorkspaceSessionPaneIdentities(session, prior?.terminalLayoutsByTabId) for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) @@ -93,7 +89,6 @@ export function setLocalWorkspaceSession( if (remappedLeases.changed) { context.runtime.state.sshRemotePtyLeases = remappedLeases.leases } - session = preserveMissingWorkspaceSessionTerminalBindings(session, prior, context.bindingRecovery) session = pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) if (!deferSnapshotFiles) { const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( diff --git a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts b/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts deleted file mode 100644 index d41b973f3ab7..000000000000 --- a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { preserveMissingWorkspaceSessionTerminalBindings } from './workspace-session-terminal-binding-replay' - -const LEAF_ONE = '11111111-1111-4111-8111-111111111111' -const LEAF_TWO = '22222222-2222-4222-8222-222222222222' -const WORKTREE_A = 'worktree-a' -const WORKTREE_B = 'worktree-b' - -function session(ptyId: string | null): WorkspaceSessionState { - return { - activeRepoId: 'repo', - activeWorktreeId: 'worktree', - activeTabId: 'tab', - tabsByWorktree: { - worktree: [ - { - id: 'tab', - worktreeId: 'worktree', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId - } - ] - }, - terminalLayoutsByTabId: {} - } -} - -function terminalTab(worktreeId: string, id: string, ptyId: string | null) { - return { - id, - worktreeId, - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId - } -} - -const bindingRecovery = { - getTerminalLayoutLeafIds: (root: { leafId?: string } | null) => - new Set(root?.leafId ? [root.leafId] : []), - getConnectionIdForWorktree: () => null, - isRestorablePtyBinding: () => true, - hasRestorableSshRemotePtyLease: () => false -} - -describe('workspace session terminal binding replay', () => { - it('retains a restorable legacy tab binding when neither snapshot has a layout', () => { - const prior = session('runtime-pty') - const incoming = session(null) - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree.worktree[0]!.ptyId).toBe('runtime-pty') - }) - - it('does not retain a tab binding whose prior leaf was removed from the layout', () => { - const prior = session('runtime-pty') - prior.terminalLayoutsByTabId.tab = { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ONE]: 'runtime-pty' } - } - const incoming = session(null) - incoming.terminalLayoutsByTabId.tab = { - root: { type: 'leaf', leafId: LEAF_TWO }, - activeLeafId: LEAF_TWO, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree.worktree[0]!.ptyId).toBeNull() - }) - - it('fails closed when one tab id is duplicated across worktrees', () => { - const prior = session(null) - prior.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-a')], - [WORKTREE_B]: [terminalTab(WORKTREE_B, 'duplicate-tab', 'pty-b')] - } - prior.terminalLayoutsByTabId = { - 'duplicate-tab': { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ONE]: 'pty-a' } - } - } - const incoming = session(null) - incoming.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', null)], - [WORKTREE_B]: [terminalTab(WORKTREE_B, 'duplicate-tab', null)] - } - incoming.terminalLayoutsByTabId = { - 'duplicate-tab': { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBeNull() - expect(incoming.tabsByWorktree[WORKTREE_B]![0]!.ptyId).toBeNull() - expect(incoming.terminalLayoutsByTabId['duplicate-tab']?.ptyIdsByLeafId).toEqual({}) - }) - - it('still replays bindings for distinct tab ids in separate worktrees', () => { - const prior = session(null) - prior.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'tab-a', 'pty-a')], - [WORKTREE_B]: [terminalTab(WORKTREE_B, 'tab-b', 'pty-b')] - } - prior.terminalLayoutsByTabId = { - 'tab-a': { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ONE]: 'pty-a' } - }, - 'tab-b': { - root: { type: 'leaf', leafId: LEAF_TWO }, - activeLeafId: LEAF_TWO, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_TWO]: 'pty-b' } - } - } - const incoming = session(null) - incoming.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'tab-a', null)], - [WORKTREE_B]: [terminalTab(WORKTREE_B, 'tab-b', null)] - } - incoming.terminalLayoutsByTabId = { - 'tab-a': { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: {} - }, - 'tab-b': { - root: { type: 'leaf', leafId: LEAF_TWO }, - activeLeafId: LEAF_TWO, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBe('pty-a') - expect(incoming.tabsByWorktree[WORKTREE_B]![0]!.ptyId).toBe('pty-b') - expect(incoming.terminalLayoutsByTabId['tab-a']?.ptyIdsByLeafId).toEqual({ - [LEAF_ONE]: 'pty-a' - }) - expect(incoming.terminalLayoutsByTabId['tab-b']?.ptyIdsByLeafId).toEqual({ - [LEAF_TWO]: 'pty-b' - }) - }) - - it('fails closed when one tab id is duplicated inside a single worktree list', () => { - // Map indexing is last-wins where a linear find was first-wins; the ambiguity - // fence must skip these ids so the two strategies can never disagree. - const prior = session(null) - prior.tabsByWorktree = { - [WORKTREE_A]: [ - terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-first'), - terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-last') - ] - } - const incoming = session(null) - incoming.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', null)] - } - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBeNull() - }) - - it('indexes prior tabs once when replaying a large workspace snapshot', () => { - let reads = 0 - const prior = session(null) - prior.tabsByWorktree.worktree = Array.from({ length: 1000 }, (_, i) => ({ - ...terminalTab('worktree', `tab-${i}`, `pty-${i}`), - get id() { - reads++ - return `tab-${i}` - } - })) - const incoming = session(null) - incoming.tabsByWorktree.worktree = Array.from({ length: 1000 }, (_, i) => - terminalTab('worktree', `tab-${i}`, null) - ) - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - expect(reads).toBeLessThan(10_000) - expect(incoming.tabsByWorktree.worktree.map((tab) => tab.ptyId)).toEqual( - Array.from({ length: 1000 }, (_, i) => `pty-${i}`) - ) - }) -}) diff --git a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.ts b/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.ts deleted file mode 100644 index 8e805b67b50d..000000000000 --- a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { getPtyExecutionHost } from '../../../shared/terminal-execution-host' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { preserveMissingLeafRecordEntries } from '../restoring-sessions/terminal-layout-normalization' - -import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' - -type TerminalBindingRecovery = Pick< - TerminalBindingRecoveryOperations, - | 'getTerminalLayoutLeafIds' - | 'getConnectionIdForWorktree' - | 'isRestorablePtyBinding' - | 'hasRestorableSshRemotePtyLease' -> - -/** A bare tab id cannot select one binding when persisted rows disagree on its worktree. */ -function collectAmbiguousTabIds( - tabsByWorktree: WorkspaceSessionState['tabsByWorktree'] -): ReadonlySet { - const ownerByTabId = new Map() - const ambiguous = new Set() - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - if (ownerByTabId.has(tab.id)) { - ambiguous.add(tab.id) - } else { - ownerByTabId.set(tab.id, worktreeId) - } - } - } - return ambiguous -} - -export type WorkspaceSessionTerminalBindingReplayOptions = { - /** Resolve the SSH target that owns bindings in a host partition. */ - targetIdForWorktree?: (worktreeId: string) => string | null - /** Reject a binding that explicitly names another execution host. */ - executionHostId?: ExecutionHostId -} - -function ptyBindingMatchesExecutionHost( - ptyId: string, - executionHostId: ExecutionHostId | undefined -): boolean { - if (!executionHostId || executionHostId === LOCAL_EXECUTION_HOST_ID) { - return true - } - const owner = getPtyExecutionHost(ptyId) - // Legacy unscoped ids carry no host proof; lease/context checks below still - // decide whether they are restorable. Known foreign ids must never cross a partition. - return owner === null || owner === executionHostId -} - -/** - * Reapplies durable pane bindings omitted by an older or in-flight renderer snapshot. - * - * An empty binding map is ambiguous: it can be a pre-spawn snapshot, or an intentional close. - * Lease/tombstone state is the authority that distinguishes those cases. A partial map is only - * repaired when a live SSH lease proves the omitted sibling still belongs to this host. - */ -export function preserveMissingWorkspaceSessionTerminalBindings( - session: WorkspaceSessionState, - prior: WorkspaceSessionState | undefined, - bindingRecovery: TerminalBindingRecovery, - options: WorkspaceSessionTerminalBindingReplayOptions = {} -): WorkspaceSessionState { - if (!prior) { - return session - } - - const priorTabs = prior.tabsByWorktree ?? {} - const nextTabs = session.tabsByWorktree ?? {} - const priorLayouts = prior.terminalLayoutsByTabId ?? {} - const nextLayouts = session.terminalLayoutsByTabId ?? {} - // Both snapshots are independently allowed to contain the same id during a - // normal worktree move. Only ids duplicated within one snapshot are unsafe; - // skip their replay rather than assigning a global layout to an arbitrary row. - const ambiguousTabIds = new Set([ - ...collectAmbiguousTabIds(priorTabs), - ...collectAmbiguousTabIds(nextTabs) - ]) - const targetIdForWorktree = - options.targetIdForWorktree ?? - ((worktreeId: string) => bindingRecovery.getConnectionIdForWorktree(worktreeId)) - - // Keep a tab-level binding when the renderer has not observed the host's spawn yet. - for (const [worktreeId, tabs] of Object.entries(nextTabs)) { - const priorList = priorTabs[worktreeId] - if (!priorList) { - continue - } - let priorById: Map | undefined - for (const tab of tabs) { - if (ambiguousTabIds.has(tab.id)) { - continue - } - if (tab.ptyId) { - continue - } - priorById ??= new Map(priorList.map((candidate) => [candidate.id, candidate])) - const priorTab = priorById.get(tab.id) - const incomingLayout = nextLayouts[tab.id] - const priorLayout = priorLayouts[tab.id] - const priorPtyLeafId = priorLayout - ? Object.entries(priorLayout.ptyIdsByLeafId ?? {}).find( - ([, ptyId]) => ptyId === priorTab?.ptyId - )?.[0] - : undefined - const bindingLeafWasRemoved = - incomingLayout !== undefined && - priorPtyLeafId !== undefined && - !bindingRecovery.getTerminalLayoutLeafIds(incomingLayout.root).has(priorPtyLeafId) - if ( - priorTab?.ptyId && - !bindingLeafWasRemoved && - ptyBindingMatchesExecutionHost(priorTab.ptyId, options.executionHostId) && - bindingRecovery.isRestorablePtyBinding({ - ptyId: priorTab.ptyId, - worktreeId, - targetId: targetIdForWorktree(worktreeId), - tabId: tab.id - }) - ) { - tab.ptyId = priorTab.ptyId - } - } - } - - const worktreeIdByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - - for (const [tabId, layout] of Object.entries(nextLayouts)) { - if (ambiguousTabIds.has(tabId)) { - continue - } - const priorLayout = priorLayouts[tabId] - if (!priorLayout?.ptyIdsByLeafId) { - continue - } - const incoming = layout.ptyIdsByLeafId ?? {} - const incomingHasAnyBinding = Object.keys(incoming).length > 0 - const liveLeafIds = bindingRecovery.getTerminalLayoutLeafIds(layout.root) - const worktreeId = worktreeIdByTabId.get(tabId) - const targetId = worktreeId ? targetIdForWorktree(worktreeId) : null - const restorableBindings = Object.fromEntries( - Object.entries(priorLayout.ptyIdsByLeafId).filter( - ([leafId, ptyId]) => - liveLeafIds.has(leafId) && - incoming[leafId] === undefined && - ptyBindingMatchesExecutionHost(ptyId, options.executionHostId) && - // An empty map may be a stale pre-spawn snapshot; a partial map is intentional unless - // a durable SSH lease proves the omitted sibling is still live on this host. - (incomingHasAnyBinding - ? bindingRecovery.hasRestorableSshRemotePtyLease({ - ptyId, - targetId, - worktreeId, - tabId, - leafId - }) - : bindingRecovery.isRestorablePtyBinding({ - ptyId, - targetId, - worktreeId, - tabId, - leafId - })) - ) - ) - if (Object.keys(restorableBindings).length === 0) { - continue - } - - layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } - // Keep pane metadata alongside a binding rescued from a stale renderer write. - const buffersByLeafId = preserveMissingLeafRecordEntries( - priorLayout.buffersByLeafId, - layout.buffersByLeafId, - liveLeafIds - ) - const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( - priorLayout.scrollbackRefsByLeafId, - layout.scrollbackRefsByLeafId, - liveLeafIds - ) - const titlesByLeafId = preserveMissingLeafRecordEntries( - priorLayout.titlesByLeafId, - layout.titlesByLeafId, - liveLeafIds - ) - if (buffersByLeafId) { - layout.buffersByLeafId = buffersByLeafId - } - if (scrollbackRefsByLeafId) { - layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId - } - if (titlesByLeafId) { - layout.titlesByLeafId = titlesByLeafId - } - } - - return session -} - -/** Target resolver for a persisted execution-host partition. */ -export function sshTargetIdForWorkspaceSessionHost( - hostId: ExecutionHostId -): ((worktreeId: string) => string | null) | undefined { - if (hostId === LOCAL_EXECUTION_HOST_ID) { - return undefined - } - const parsed = parseExecutionHostId(hostId) - return parsed?.kind === 'ssh' ? () => parsed.targetId : () => null -} diff --git a/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts b/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts index 9359a2d8f96f..9e5027fa1fff 100644 --- a/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts +++ b/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts @@ -3,10 +3,8 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' -import { - TerminalTopologyPublisher, - type WorkspaceSessionOwner -} from '../../runtime/terminal-topology-publisher' +import { TerminalTopologyPublisher } from '../../runtime/terminal-topology-publisher' +import type { TerminalSessionPartition } from '../terminal-topology/terminal-topology-membership' import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' import { fixture } from './profile-state-delayed-authority-fixture' import type { Store } from './store' @@ -32,8 +30,8 @@ const binding = { type Mode = 'none' | 'publisher' | 'throwing' -function ownersOf(store: Store): Map { - const owners = new Map() +function ownersOf(store: Store): Map { + const owners = new Map() for (const hostId of store.getWorkspaceSessionHostIds()) { const session = store.getWorkspaceSession(hostId) for (const worktreeId of Object.keys(session.tabsByWorktree)) { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts index 6ad39576d5fa..19022b7dad03 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts @@ -163,6 +163,24 @@ describe('exporting a relay-hosted SSH target from the profile store', () => { expect(readTerminalScrollbackStoredBytesSync(ref, storage)).toBeNull() }) + it('a save that drops a tab keeps the scrollback file a pending migration still sends', () => { + const store = sourceStore() + const storage = { snapshotRoot: getProfileTerminalScrollbackSnapshotRoot(dataFiles.at(-1)!) } + store.setWorkspaceSession(getDefaultWorkspaceSession(), toSshExecutionHostId(TARGET.id)) + // An older build left this SSH workspace's tab in the local partition. + store.setWorkspaceSession(dormantSession('legacy output\r\n')) + const manifest = createOrcadMigrationManifest(store, TARGET) + const ref = manifest.payload.dormantState?.terminalScrollbackSnapshots?.[0]?.ref ?? '' + expect(readTerminalScrollbackStoredBytesSync(ref, storage)).not.toBeNull() + store.syncOrcadMigrationScrollbackRetention([manifest]) + + store.setWorkspaceSession(getDefaultWorkspaceSession()) + + expect(readTerminalScrollbackStoredBytesSync(ref, storage)).not.toBeNull() + store.syncOrcadMigrationScrollbackRetention([]) + expect(readTerminalScrollbackStoredBytesSync(ref, storage)).toBeNull() + }) + it('refuses a chunk for a manifest whose digest does not match its contents', () => { const store = sourceStore() const manifest = createOrcadMigrationManifest(store, TARGET) diff --git a/src/main/persistence/restoring-sessions/normalized-pane-incarnations.ts b/src/main/persistence/restoring-sessions/normalized-pane-incarnations.ts new file mode 100644 index 000000000000..58cba8352c15 --- /dev/null +++ b/src/main/persistence/restoring-sessions/normalized-pane-incarnations.ts @@ -0,0 +1,29 @@ +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { layoutContainsLeafId } from './terminal-layout-normalization' + +/** The session with its normalized layouts; a pane the normalizer dropped takes its PTY incarnation. */ +export function withKeptPaneIncarnations( + session: WorkspaceSessionState, + terminalLayoutsByTabId: Record +): WorkspaceSessionState { + const incarnations = session.terminalPtyIncarnationsByPaneKey + if (!incarnations) { + return { ...session, terminalLayoutsByTabId } + } + const kept = Object.entries(incarnations).filter(([paneKey]) => { + const pane = parsePaneKey(paneKey) + const layout = pane && terminalLayoutsByTabId[pane.tabId] + return ( + !layout || + layout === session.terminalLayoutsByTabId[pane.tabId] || + layoutContainsLeafId(layout.root, pane.leafId) + ) + }) + return { + ...session, + terminalLayoutsByTabId, + terminalPtyIncarnationsByPaneKey: Object.fromEntries(kept) + } +} diff --git a/src/main/persistence/restoring-sessions/pane-key-remapping.ts b/src/main/persistence/restoring-sessions/pane-key-remapping.ts index 8e43184b0c5f..6b022575939c 100644 --- a/src/main/persistence/restoring-sessions/pane-key-remapping.ts +++ b/src/main/persistence/restoring-sessions/pane-key-remapping.ts @@ -72,3 +72,16 @@ export function remapActivityClearedAtPaneKeys( const result = remapPaneKeys(cutoffs, leafIdByInputLeafIdByTabId) return { cutoffs: result.values, changed: result.changed } } + +/** Acknowledgement keys lack host metadata, so an already-mapped tab keeps its mapping. */ +export function mergeAcknowledgementLeafIdMapsByTabId( + target: Map>, + source: Map> +): Map> { + const merged = new Map(target) + for (const [tabId, leafIds] of source) { + const existing = merged.get(tabId) + merged.set(tabId, existing ? new Map([...leafIds, ...existing]) : new Map(leafIds)) + } + return merged +} diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index 1ac9fec8935b..a0df9ec8bde5 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -22,7 +22,7 @@ export const OWNER_KEYED_SESSION_FIELDS_DELETED_WITH_THEIR_OWNER = export function createMinimalPersistedTerminalTab(args: { worktreeId: string tabId: string - ptyId: string + ptyId: string | null existingTabCount: number startupCwd?: string }): TerminalTab { diff --git a/src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts b/src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts index cfcdac1d8054..bd529f741b05 100644 --- a/src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts +++ b/src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts @@ -53,3 +53,44 @@ describe('normalizeTerminalLayoutSnapshotForPersistence', () => { expect(collectLayoutLeafIdsInOrder(normalized.snapshot.root)).toEqual([STABLE_A, STABLE_B]) }) }) + +describe('normalizeTerminalLayoutSnapshotForPersistence pane metadata', () => { + it('keeps a null focused pane and remaps the expanded and chat panes with their leaf', () => { + const normalized = normalizeTerminalLayoutSnapshotForPersistence( + { + ...splitOf('pane:1', 'pane:2'), + activeLeafId: null, + expandedLeafId: 'pane:2', + chatLeafId: 'pane:1' + }, + splitOf(STABLE_A, STABLE_B) + ) + + expect(normalized.snapshot).toMatchObject({ + activeLeafId: null, + expandedLeafId: STABLE_B, + chatLeafId: STABLE_A + }) + }) + + it('drops an expanded or chat pane that names a leaf no longer in the tree', () => { + const normalized = normalizeTerminalLayoutSnapshotForPersistence({ + ...splitOf(STABLE_A, STABLE_B), + expandedLeafId: 'pane:gone', + chatLeafId: 'pane:gone' + }) + + expect(normalized.snapshot.expandedLeafId).toBeNull() + expect(normalized.snapshot).not.toHaveProperty('chatLeafId') + }) + + it('gives a createTab-era empty layout the prior tree back', () => { + const normalized = normalizeTerminalLayoutSnapshotForPersistence( + { root: null, activeLeafId: null, expandedLeafId: null }, + { ...splitOf(STABLE_A, STABLE_B), activeLeafId: STABLE_B } + ) + + expect(collectLayoutLeafIdsInOrder(normalized.snapshot.root)).toEqual([STABLE_A, STABLE_B]) + expect(normalized.snapshot.activeLeafId).toBe(STABLE_B) + }) +}) diff --git a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts index 551d57e6e35c..b7504a1c47d3 100644 --- a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts +++ b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts @@ -4,6 +4,7 @@ import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { normalizeTerminalLayoutPtyOwnership } from '../../../shared/terminal-layout-pty-ownership' export type LayoutLeafNormalization = { snapshot: TerminalLayoutSnapshot @@ -117,21 +118,17 @@ export function leafRecordEquivalent( return leftEntries.every(([key, value]) => rightRecord[key] === value) } -export function preserveMissingLeafRecordEntries( - priorRecord: Record | undefined, - incomingRecord: Record | undefined, - liveLeafIds: Set -): Record | undefined { - const preserved = Object.fromEntries( - Object.entries(priorRecord ?? {}).filter( - ([leafId]) => liveLeafIds.has(leafId) && incomingRecord?.[leafId] === undefined - ) - ) - const next = { ...preserved, ...incomingRecord } - return Object.keys(next).length > 0 ? next : undefined +/** One pane per PTY in a tab, as a window keeps it (#11757), then one stable id per pane. */ +export function normalizeTerminalLayoutSnapshotForPersistence( + snapshot: TerminalLayoutSnapshot, + preferredLayout?: TerminalLayoutSnapshot +): LayoutLeafNormalization { + const owned = normalizeTerminalLayoutPtyOwnership(snapshot) + const leaves = normalizeLayoutLeafIds(owned.snapshot, preferredLayout) + return { ...leaves, changed: owned.changed || leaves.changed } } -export function normalizeTerminalLayoutSnapshotForPersistence( +function normalizeLayoutLeafIds( snapshot: TerminalLayoutSnapshot, preferredLayout?: TerminalLayoutSnapshot ): LayoutLeafNormalization { diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts index 9cebe041544d..4e2b19ea95aa 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts @@ -181,4 +181,41 @@ describe('workspace pane normalization tab index', () => { expect(idReads).toBe(0) }) + + it('keeps one pane of a tab whose saved layout binds two to one PTY', () => { + const second = '00000000-0000-4000-8000-000000000002' + const layout: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: STABLE_LEAF_ID }, + second: { type: 'leaf', leafId: second } + }, + activeLeafId: second, + expandedLeafId: null, + ptyIdsByLeafId: { [STABLE_LEAF_ID]: 'pty-1', [second]: 'pty-1' } + } + + const result = normalizeWorkspaceSessionPaneIdentities({ + ...workspaceSession({ wt: [terminalTab('tab', 'wt', 'pty-1')] }, { tab: layout }), + terminalPtyIncarnationsByPaneKey: { + [`tab:${STABLE_LEAF_ID}`]: 'incarnation-dropped', + [`tab:${second}`]: 'incarnation-kept', + 'unlaid:leaf': 'incarnation-of-a-tab-without-layout' + } + }) + + expect(result.changed).toBe(true) + expect(result.session.terminalLayoutsByTabId.tab).toEqual({ + root: { type: 'leaf', leafId: second }, + activeLeafId: second, + expandedLeafId: null, + ptyIdsByLeafId: { [second]: 'pty-1' } + }) + // The dropped pane's incarnation goes with it, as a save used to prune it. + expect(result.session.terminalPtyIncarnationsByPaneKey).toEqual({ + [`tab:${second}`]: 'incarnation-kept', + 'unlaid:leaf': 'incarnation-of-a-tab-without-layout' + }) + }) }) diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index 38ec014c7c96..1d0fde7445e1 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -17,6 +17,7 @@ import { type PaneAliasNormalizationOptions } from './pane-identity-migration' import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization' +import { withKeptPaneIncarnations } from './normalized-pane-incarnations' import { legacyMigrationUnsupportedRowsToAliasEntries, legacyPaneKeyAliasEntriesEqual, @@ -25,6 +26,7 @@ import { normalizeLegacyPaneKeyAliasEntries } from './pane-alias-normalization' import { + mergeAcknowledgementLeafIdMapsByTabId, remapAcknowledgedAgentPaneKeys, remapActivityClearedAtPaneKeys, remapManuallyUnreadTurnPaneKeys @@ -103,7 +105,7 @@ export function normalizeWorkspaceSessionPaneIdentities( changed ||= normalized.changed } return { - session: changed ? { ...session, terminalLayoutsByTabId } : session, + session: changed ? withKeptPaneIncarnations(session, terminalLayoutsByTabId) : session, changed, leafIdByInputLeafIdByTabId, leafIdByPtyIdByTabId, @@ -161,19 +163,6 @@ export function remapSshRemotePtyLeaseLeafIds( return { leases: nextLeases, changed } } -/** Acknowledgement keys lack host metadata, so an already-mapped tab keeps its mapping. */ -function mergeAcknowledgementLeafIdMapsByTabId( - target: Map>, - source: Map> -): Map> { - const merged = new Map(target) - for (const [tabId, leafIds] of source) { - const existing = merged.get(tabId) - merged.set(tabId, existing ? new Map([...leafIds, ...existing]) : new Map(leafIds)) - } - return merged -} - export function normalizePersistedPaneIdentityState( state: PersistedState, options: PaneAliasNormalizationOptions = {} diff --git a/src/main/persistence/runtime-authored-workspace-session-fields.test.ts b/src/main/persistence/runtime-authored-workspace-session-fields.test.ts deleted file mode 100644 index 5f5ca100f697..000000000000 --- a/src/main/persistence/runtime-authored-workspace-session-fields.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { preserveRuntimeAuthoredWorkspaceSessionFields } from './runtime-authored-workspace-session-fields' - -const row = { - v: 1 as const, - browserPageId: 'page-a', - workspaceId: 'repo-1::wt-a', - browserProfileId: 'profile-a', - executionHostKey: 'native:runtime-a:1', - url: 'https://kept.internal/', - title: 'Kept', - pairedDeviceId: 'device-a', - savedAt: 1_800_000_000_000 -} - -describe('preserving runtime-authored workspace session fields', () => { - it('carries the rows across a write that never mentions them', () => { - // The desktop renderer builds its payload from Zustand, which has no idea the runtime - // authority sharing this profile persists client-hosted pages. - const next = preserveRuntimeAuthoredWorkspaceSessionFields( - session(), - session({ 'repo-1::wt-a': [row] }) - ) - - expect(next.clientHostedBrowserPagesByWorktree).toEqual({ 'repo-1::wt-a': [row] }) - }) - - it('lets the runtime clear its own rows, which it does with an empty map', () => { - const next = preserveRuntimeAuthoredWorkspaceSessionFields( - session({}), - session({ 'repo-1::wt-a': [row] }) - ) - - expect(next.clientHostedBrowserPagesByWorktree).toEqual({}) - }) - - it('lets the runtime replace its rows outright', () => { - const replaced = { 'repo-1::wt-b': [{ ...row, workspaceId: 'repo-1::wt-b' }] } - - expect( - preserveRuntimeAuthoredWorkspaceSessionFields( - session(replaced), - session({ 'repo-1::wt-a': [row] }) - ).clientHostedBrowserPagesByWorktree - ).toEqual(replaced) - }) - - it('treats an explicit undefined as never having mentioned the field', () => { - // The one ambiguous input: a writer that spreads the field through as undefined is still a - // writer that knows nothing about it, so it must inherit rather than clear. - const next = preserveRuntimeAuthoredWorkspaceSessionFields( - { ...session(), clientHostedBrowserPagesByWorktree: undefined }, - session({ 'repo-1::wt-a': [row] }) - ) - - expect(next.clientHostedBrowserPagesByWorktree).toEqual({ 'repo-1::wt-a': [row] }) - }) - - it('leaves an untouched write alone rather than inventing a field', () => { - const next = session() - - expect(preserveRuntimeAuthoredWorkspaceSessionFields(next, session())).toBe(next) - expect(preserveRuntimeAuthoredWorkspaceSessionFields(next, null)).toBe(next) - }) - - it('keeps default-terminal-tab markers a persist snapshot omitted', () => { - const next = preserveRuntimeAuthoredWorkspaceSessionFields(session(), { - ...session(), - defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/wt-a': true } - }) - - expect(next.defaultTerminalTabsAppliedByWorktreeId).toEqual({ 'repo-1::/wt-a': true }) - }) - - it('unions default-terminal-tab markers instead of replacing with a partial snapshot', () => { - const next = preserveRuntimeAuthoredWorkspaceSessionFields( - { ...session(), defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/wt-b': true } }, - { ...session(), defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/wt-a': true } } - ) - - expect(next.defaultTerminalTabsAppliedByWorktreeId).toEqual({ - 'repo-1::/wt-a': true, - 'repo-1::/wt-b': true - }) - }) -}) - -function session( - clientHostedBrowserPagesByWorktree?: WorkspaceSessionState['clientHostedBrowserPagesByWorktree'] -): WorkspaceSessionState { - return { - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - ...(clientHostedBrowserPagesByWorktree ? { clientHostedBrowserPagesByWorktree } : {}) - } -} diff --git a/src/main/persistence/runtime-authored-workspace-session-fields.ts b/src/main/persistence/runtime-authored-workspace-session-fields.ts deleted file mode 100644 index 80a9d30b532d..000000000000 --- a/src/main/persistence/runtime-authored-workspace-session-fields.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -/** - * Keeps session fields the renderer persist snapshot does not author across a full write. - * - * A session write replaces the stored object. Zustand-built payloads omit runtime-owned - * client-hosted pages and terminal close records, and they omit-when-empty the write-once - * default-terminal-tab marker. Without this, those slices vanish on the next desktop write and - * only show up missing after restart. - * - * Callers do not opt in: the Store applies this inside setLocalWorkspaceSession and - * setHostWorkspaceSession, so the before-unload stage path inherits it too. - * - * A runtime clearing client-hosted pages writes an empty map, not `undefined`. The default-tab - * marker is write-once: omission means "never stamped", not "never applied". - */ -function unionWriteOnceDefaultTerminalTabsApplied( - next: WorkspaceSessionState, - prior: WorkspaceSessionState | null | undefined -): WorkspaceSessionState { - const priorMarks = prior?.defaultTerminalTabsAppliedByWorktreeId - if (!priorMarks || Object.keys(priorMarks).length === 0) { - return next - } - const nextMarks = next.defaultTerminalTabsAppliedByWorktreeId - const merged = { ...priorMarks, ...nextMarks } - if (nextMarks && Object.keys(merged).length === Object.keys(nextMarks).length) { - return next - } - return { ...next, defaultTerminalTabsAppliedByWorktreeId: merged } -} - -export function preserveRuntimeAuthoredWorkspaceSessionFields( - next: WorkspaceSessionState, - prior: WorkspaceSessionState | null | undefined -): WorkspaceSessionState { - let result = next - if ( - next.clientHostedBrowserPagesByWorktree === undefined && - prior?.clientHostedBrowserPagesByWorktree !== undefined - ) { - result = { - ...result, - clientHostedBrowserPagesByWorktree: prior.clientHostedBrowserPagesByWorktree - } - } - // Why: close records are main's alone (its close transaction); a renderer save never carries them. - if ( - next.closedTerminalTabTombstonesByTabId === undefined && - prior?.closedTerminalTabTombstonesByTabId !== undefined - ) { - result = { - ...result, - closedTerminalTabTombstonesByTabId: prior.closedTerminalTabTombstonesByTabId - } - } - // Why union: persist snapshots omit this write-once map (empty Zustand slice, omit-when-empty - // payload). Treating omission as "never applied" re-spawns default terminals on every attach. - return unionWriteOnceDefaultTerminalTabsApplied(result, prior) -} diff --git a/src/main/persistence/terminal-topology/terminal-layout-set.test.ts b/src/main/persistence/terminal-topology/terminal-layout-set.test.ts new file mode 100644 index 000000000000..6538f74ca2f8 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-layout-set.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultUIState } from '../../../shared/constants' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { setLayout } from './terminal-topology-commit' +import { assignWorkspaceSessionPartition } from './terminal-topology-membership' + +const WT = 'repo::/wt' +const TAB = 'tab-1' +const A = '11111111-1111-4111-8111-111111111111' +const B = '22222222-2222-4222-8222-222222222222' +const C = '33333333-3333-4333-8333-333333333333' + +const leaf = (leafId: string): TerminalPaneLayoutNode => ({ type: 'leaf', leafId }) +const split = ( + first: TerminalPaneLayoutNode, + second: TerminalPaneLayoutNode, + ratio?: number +): TerminalPaneLayoutNode => ({ + type: 'split', + direction: 'vertical', + first, + second, + ...(ratio !== undefined ? { ratio } : {}) +}) + +function session(root: TerminalPaneLayoutNode): WorkspaceSessionState { + return { + activeRepoId: 'repo', + activeWorktreeId: WT, + activeTabId: TAB, + tabsByWorktree: { + [WT]: [ + { + id: TAB, + ptyId: 'pty-a', + worktreeId: WT, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB]: { + root, + activeLeafId: A, + expandedLeafId: null, + ptyIdsByLeafId: { [A]: 'pty-a', [B]: 'pty-b' } + } + } + } +} + +function profile(partitions: [ExecutionHostId, WorkspaceSessionState][]) { + const state: Parameters[2]['state'] = { + workspaceSession: session(leaf(C)), + workspaceSessionsByHostId: {}, + ui: getDefaultUIState(), + sshRemotePtyLeases: [] + } + const dirty = new Set() + const getSession = (hostId: ExecutionHostId): WorkspaceSessionState => + (hostId === 'local' ? state.workspaceSession : state.workspaceSessionsByHostId?.[hostId]) ?? { + ...session(leaf(C)), + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + } + const context: Parameters[2] = { + state, + getSession, + markDirty: (domain) => dirty.add(domain) + } + for (const [hostId, value] of partitions) { + assignWorkspaceSessionPartition(state, hostId, value) + } + const rootOf = (hostId: ExecutionHostId) => getSession(hostId).terminalLayoutsByTabId?.[TAB]?.root + return { state, dirty, context, rootOf } +} + +describe('setLayout', () => { + it('commits a same-pane tree and keeps everything else in the layout', () => { + const { context, dirty, rootOf, state } = profile([['local', session(split(leaf(A), leaf(B)))]]) + const dragged = split(leaf(A), leaf(B), 0.3) + + const result = setLayout({ worktreeId: WT, tabId: TAB, root: dragged }, 'local', context)() + + expect(result.value).toEqual({ status: 'committed' }) + expect(result.persist).not.toBe(false) + expect(rootOf('local')).toEqual(dragged) + expect(state.workspaceSession.terminalLayoutsByTabId?.[TAB]?.ptyIdsByLeafId).toEqual({ + [A]: 'pty-a', + [B]: 'pty-b' + }) + expect([...dirty]).toEqual(['workspaceSession']) + }) + + it('takes a reordered tree, which keeps the same panes', () => { + const { context, rootOf } = profile([['local', session(split(leaf(A), leaf(B)))]]) + const swapped = split(leaf(B), leaf(A)) + expect( + setLayout({ worktreeId: WT, tabId: TAB, root: swapped }, 'local', context)().value + ).toEqual({ + status: 'committed' + }) + expect(rootOf('local')).toEqual(swapped) + }) + + it.each([ + ['an extra pane', split(split(leaf(A), leaf(B)), leaf(C))], + ['a missing pane', leaf(A)], + ['another pane in place of one', split(leaf(A), leaf(C))], + ['a pane twice', split(split(leaf(A), leaf(B)), leaf(B))] + ])('refuses a tree with %s and writes nothing', (_name, root) => { + const before = split(leaf(A), leaf(B)) + const { context, dirty, rootOf } = profile([['local', session(before)]]) + + const result = setLayout({ worktreeId: WT, tabId: TAB, root }, 'local', context)() + + expect(result.value).toEqual({ status: 'refused', reason: 'leaves_differ' }) + expect(result.persist).toBe(false) + expect(rootOf('local')).toEqual(before) + expect(dirty.size).toBe(0) + }) + + it('refuses a tab main does not hold', () => { + const { context } = profile([['local', session(split(leaf(A), leaf(B)))]]) + const result = setLayout({ worktreeId: WT, tabId: 'other', root: leaf(A) }, 'local', context)() + expect(result.value).toEqual({ status: 'refused', reason: 'tab_not_held' }) + }) + + it('writes nothing for the tree main already holds', () => { + const root = split(leaf(A), leaf(B), 0.4) + const { context, dirty } = profile([['local', session(root)]]) + const result = setLayout( + { worktreeId: WT, tabId: TAB, root: structuredClone(root) }, + 'local', + context + )() + expect(result).toEqual({ value: { status: 'committed' }, persist: false }) + expect(dirty.size).toBe(0) + }) + + it('writes only the home partition, leaving a stray copy of the tab untouched', () => { + const before = split(leaf(A), leaf(B)) + const { context, dirty, rootOf } = profile([ + ['local', session(before)], + ['ssh:target', session(before)] + ]) + const dragged = split(leaf(A), leaf(B), 0.7) + + setLayout({ worktreeId: WT, tabId: TAB, root: dragged }, 'ssh:target', context)() + + expect(rootOf('ssh:target')).toEqual(dragged) + expect(rootOf('local')).toEqual(before) + expect([...dirty]).toEqual(['workspaceSessionsByHostId']) + }) + + it('refuses when only another partition holds the tab', () => { + const { context } = profile([['local', session(split(leaf(A), leaf(B)))]]) + const result = setLayout( + { worktreeId: WT, tabId: TAB, root: split(leaf(A), leaf(B), 0.7) }, + 'ssh:target', + context + )() + expect(result.value).toEqual({ status: 'refused', reason: 'tab_not_held' }) + }) + + it('rolls back to the prior tree', () => { + const before = split(leaf(A), leaf(B)) + const { context, rootOf } = profile([['local', session(before)]]) + const result = setLayout( + { worktreeId: WT, tabId: TAB, root: split(leaf(A), leaf(B), 0.2) }, + 'local', + context + )() + result.rollback?.() + expect(rootOf('local')).toEqual(before) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-layout-set.ts b/src/main/persistence/terminal-topology/terminal-layout-set.ts new file mode 100644 index 000000000000..0aafe0a59257 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-layout-set.ts @@ -0,0 +1,43 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + TerminalLayoutSetRequest, + TerminalLayoutSetResult +} from '../../../shared/terminal-layout-set' +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' + +const sortedLeafIds = (root: TerminalPaneLayoutNode | null | undefined): string[] => + collectLayoutLeafIdsInOrder(root).sort() + +export const sameTerminalLeafSet = ( + a: TerminalPaneLayoutNode | null | undefined, + b: TerminalPaneLayoutNode | null | undefined +): boolean => isDeepStrictEqual(sortedLeafIds(a), sortedLeafIds(b)) + +/** + * Replaces a tab's tree in the worktree's home partition with the window's, when it holds exactly + * the same panes. A geometry edit never adds, removes or moves a pane; a tree that would is + * refused, not merged. `session` is null when nothing changes. + */ +export function planTerminalLayoutSet( + home: WorkspaceSessionState, + { worktreeId, tabId, root }: TerminalLayoutSetRequest +): { result: TerminalLayoutSetResult; session: WorkspaceSessionState | null } { + const layout = home.terminalLayoutsByTabId?.[tabId] + if (!layout?.root || !home.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId)) { + return { result: { status: 'refused', reason: 'tab_not_held' }, session: null } + } + if (!sameTerminalLeafSet(layout.root, root)) { + return { result: { status: 'refused', reason: 'leaves_differ' }, session: null } + } + return { + result: { status: 'committed' }, + session: isDeepStrictEqual(layout.root, root) + ? null + : { + ...home, + terminalLayoutsByTabId: { ...home.terminalLayoutsByTabId, [tabId]: { ...layout, root } } + } + } +} diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts index d5d0a89c4bcb..32bbd5c89faa 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts @@ -64,7 +64,7 @@ async function openMovedStore() { const store = openStore(dataFile) store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) await seedSplitSource(store) - await expect(store.moveTerminalLeafToNewTab(request)).resolves.toMatchObject({ + await expect(store.moveTerminalLeafToNewTab(request, 'local')).resolves.toMatchObject({ status: 'moved' }) return { dataFile, store } @@ -108,7 +108,7 @@ describe('a close that lands while main commits the move', () => { it('grafts a late spawn result for the moved leaf back into its source tab', async () => { const store = openStore(newDataFile()) await seedSplitSource(store) - await store.moveTerminalLeafToNewTab({ ...request, ptyId: null }) + await store.moveTerminalLeafToNewTab({ ...request, ptyId: null }, 'local') await store.persistPtyBinding({ worktreeId: WT, tabId: SOURCE, diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts index 14ff2d7b764a..5e7252f9acd9 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts @@ -4,6 +4,7 @@ import { toSshExecutionHostId } from '../../../shared/execution-host' import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' import { makeRepo, makeTerminalTab } from '../../persistence-test-harness' import { planTerminalLeafMove } from './terminal-leaf-move' +import { setRendererSession } from './terminal-renderer-presentation-save' import { closeMoveTestStores, FROM, @@ -57,7 +58,7 @@ describe('moving a pane to a new tab', () => { }) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, 'local') ).resolves.toEqual({ status: 'moved', ptyId: 'pty-agent' }) const session = store.getWorkspaceSession() @@ -85,7 +86,7 @@ describe('moving a pane to a new tab', () => { expect(ownersOf(session, 'pty-agent')).toEqual([TO]) }) - it('re-keys the SSH lease and moves within the partition that holds the tab', async () => { + it('re-keys the SSH lease and moves within the SSH home partition', async () => { const store = openStore(newDataFile()) const hostId = toSshExecutionHostId('ssh-1') await seedSplitSource(store, hostId) @@ -99,7 +100,7 @@ describe('moving a pane to a new tab', () => { }) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, hostId) ).resolves.toMatchObject({ status: 'moved' }) expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) @@ -112,7 +113,7 @@ describe('moving a pane to a new tab', () => { it('reports a leaf main never held instead of inventing one', async () => { const store = openStore(newDataFile()) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, 'local') ).resolves.toEqual({ status: 'not_held' }) expect(store.getWorkspaceSession().tabsByWorktree[WT]).toBeUndefined() }) @@ -121,44 +122,42 @@ describe('moving a pane to a new tab', () => { const store = openStore(newDataFile()) await seedSplitSource(store) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-other' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-other' }, 'local') ).resolves.toEqual({ status: 'refused', reason: 'pty_mismatch' }) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, targetTabId: SOURCE, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab( + { ...moveRequest, targetTabId: SOURCE, ptyId: 'pty-agent' }, + 'local' + ) ).resolves.toEqual({ status: 'refused', reason: 'target_tab_exists' }) expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([SOURCE]) }) it('moves a leaf that a stray layout of a removed tab still names', () => { const planned = planTerminalLeafMove( - [ - { - hostId: 'local', - session: { - activeRepoId: 'repo-1', - activeWorktreeId: WT, - activeTabId: SOURCE, - tabsByWorktree: { [WT]: [makeTerminalTab({ id: SOURCE, worktreeId: WT })] }, - terminalLayoutsByTabId: { - [SOURCE]: { - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: LEFT }, - second: { type: 'leaf', leafId: MOVED } - }, - activeLeafId: LEFT, - expandedLeafId: null - }, - 'tab-removed': { - root: { type: 'leaf', leafId: MOVED }, - activeLeafId: MOVED, - expandedLeafId: null - } - } + { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: SOURCE, + tabsByWorktree: { [WT]: [makeTerminalTab({ id: SOURCE, worktreeId: WT })] }, + terminalLayoutsByTabId: { + [SOURCE]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: MOVED } + }, + activeLeafId: LEFT, + expandedLeafId: null + }, + 'tab-removed': { + root: { type: 'leaf', leafId: MOVED }, + activeLeafId: MOVED, + expandedLeafId: null } } - ], + }, { ...moveRequest, ptyId: 'pty-agent' } ) expect(planned.result).toEqual({ status: 'moved', ptyId: 'pty-agent' }) @@ -178,9 +177,9 @@ describe('moving a pane to a new tab', () => { await seedSplitSource(store) records.length = 0 const request = { ...moveRequest, ptyId: 'pty-agent' } - await store.moveTerminalLeafToNewTab(request) - await store.moveTerminalLeafToNewTab(request) - await store.moveTerminalLeafToNewTab({ ...request, targetTabId: 'tab-other' }) + await store.moveTerminalLeafToNewTab(request, 'local') + await store.moveTerminalLeafToNewTab(request, 'local') + await store.moveTerminalLeafToNewTab({ ...request, targetTabId: 'tab-other' }, 'local') const spans = records.filter((record) => record.name === 'persistence.terminal-topology') expect(spans.map((span) => span.attributes)).toEqual([ @@ -205,96 +204,40 @@ describe('moving a pane to a new tab', () => { }) }) -// After a restart the relay reattach writes the SSH pane into `local` as well as `ssh:`; a move -// that left either copy behind refused the moved pane and the next relay reattach. -describe('moving an SSH pane held by both partitions', () => { - it('moves every copy, so the target reattach and the next relay reattach both bind', async () => { +// A relay reattach can leave a stray copy of an SSH pane in `local`. Main writes only the worktree's +// home; the stray copy is neither moved nor repaired (D-core-2, D-core-3). +describe('moving an SSH pane whose stray copy sits in local', () => { + it('moves the home copy only and leaves the stray copy as it was', async () => { const store = openStore(newDataFile()) vi.spyOn(console, 'warn').mockImplementation(() => {}) const hostId = toSshExecutionHostId('ssh-1') await seedSplitSource(store, hostId) - const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' } - expect( - await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' }) - ).toBe(true) + await seedSplitSource(store) + const strayCopy = store.getWorkspaceSession() await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) - ).resolves.toMatchObject({ status: 'moved' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, hostId) + ).resolves.toEqual({ status: 'moved', ptyId: 'pty-agent' }) - expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) + expect(store.getWorkspaceSession()).toEqual(strayCopy) expect( - await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId) - ).toBe(true) - expect( - await store.persistPtyBinding({ - ...relay, - tabId: TARGET, - origin: 'relay_reattach', - mayReviveRetiredSurface: false - }) - ).toBe(true) - }) - - // Relay reattach into local, move, target reattach in ssh:, next-start relay reattach. - it('keeps one holder per partition across a restart, and the next relay reattach binds', async () => { - const dataFile = newDataFile() - const store = openStore(dataFile) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) - const hostId = toSshExecutionHostId('ssh-1') - await seedSplitSource(store, hostId) - const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' } - await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' }) - await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) - expect( - await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId) + await store.persistPtyBinding( + { worktreeId: WT, tabId: TARGET, leafId: MOVED, ptyId: 'pty-agent', origin: 'reattach' }, + hostId + ) ).toBe(true) - store.flush() - - const restarted = openStore(dataFile) - expect( - await restarted.persistPtyBinding({ - ...relay, - tabId: TARGET, - origin: 'relay_reattach', - mayReviveRetiredSurface: false - }) - ).toBe(true) - for (const session of [ - restarted.getWorkspaceSession(), - restarted.getWorkspaceSession(hostId) - ]) { - expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) - expect(ownersOf(session, 'pty-agent')).toEqual([TO]) - } }) - it('follows the live PTY when an SSH respawn bound one partition before the other', async () => { + it('refuses a pane only the stray copy holds', async () => { const store = openStore(newDataFile()) - vi.spyOn(console, 'warn').mockImplementation(() => {}) const hostId = toSshExecutionHostId('ssh-1') - await seedSplitSource(store, hostId) - const relay = { worktreeId: WT, tabId: SOURCE, leafId: MOVED, incarnationId: 'inc-1' } - await store.persistPtyBinding({ ...relay, ptyId: 'pty-agent', origin: 'relay_reattach' }) - await store.persistPtyBinding( - { ...relay, ptyId: 'pty-respawn', incarnationId: 'inc-2' }, - hostId - ) + await seedSplitSource(store) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-respawn' }) - ).resolves.toEqual({ status: 'moved', ptyId: 'pty-respawn' }) - - for (const session of [store.getWorkspaceSession(), store.getWorkspaceSession(hostId)]) { - expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) - expect(session.terminalLayoutsByTabId[TARGET]?.ptyIdsByLeafId).toEqual({ - [MOVED]: 'pty-respawn' - }) - } - expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[TO]).toBe('inc-2') - expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[TO]).toBeUndefined() + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, hostId) + ).resolves.toEqual({ status: 'not_held' }) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([SOURCE]) }) it('refuses a move while another tab already holds the leaf', async () => { @@ -321,7 +264,7 @@ describe('moving an SSH pane held by both partitions', () => { }) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, 'local') ).resolves.toEqual({ status: 'refused', reason: 'leaf_in_other_tab' }) }) }) @@ -338,7 +281,7 @@ describe('STA-9259 move sequence', () => { await seedSplitSource(store) const preMoveRendererSnapshot = structuredClone(store.getWorkspaceSession()) - await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, 'local') // The moved pane mounts in the target tab and reattaches with its stable-owner fence. const reattached = await store.persistPtyBinding({ worktreeId: WT, @@ -351,7 +294,7 @@ describe('STA-9259 move sequence', () => { }) expect(reattached).toBe(true) // A debounced renderer save that predates the move must not resurrect the source copy. - store.setWorkspaceSession(preMoveRendererSnapshot) + setRendererSession(store, preMoveRendererSnapshot) expect(ownersOf(store.getWorkspaceSession(), 'pty-agent')).toEqual([TO]) expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts index 627e4a705e72..75ddb2b47a6b 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -10,16 +10,11 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import { retireLeavesFromTerminalLayout } from '../../runtime/mobile-session-terminal-retirement' import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' -import { - advanceTerminalTopologyRevision, - isTerminalOwnerPartition, - type TerminalSessionPartition -} from './terminal-topology-membership' +import { advanceTerminalTopologyRevision } from './terminal-topology-membership' type PlannedTerminalLeafMove = { result: TerminalLeafMoveResult - /** Every partition the plan rewrote; empty when nothing changes. */ - sessions: TerminalSessionPartition[] + session: WorkspaceSessionState | null } function moveRecordKey( @@ -52,7 +47,8 @@ function hasTabId(session: WorkspaceSessionState, tabId: string): boolean { ) } -type SourceHolder = TerminalSessionPartition & { +type SourceHolder = { + session: WorkspaceSessionState sourceTab: TerminalTab sourceLayout: TerminalLayoutSnapshot } @@ -65,7 +61,6 @@ function moveLeafInPartition( const { worktreeId, sourceTabId, targetTabId, leafId } = request const tabs = session.tabsByWorktree?.[worktreeId] ?? [] const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) - const boundHere = sourceLayout.ptyIdsByLeafId?.[leafId] const remainingLayout = retireLeavesFromTerminalLayout(sourceLayout, new Set([leafId])) const remainingPtyIds = Object.values(remainingLayout?.ptyIdsByLeafId ?? {}) const { pendingActivationSpawn, ...row } = createMinimalPersistedTerminalTab({ @@ -120,11 +115,10 @@ function moveLeafInPartition( tabsByWorktree: { ...session.tabsByWorktree, [worktreeId]: [...nextTabs, targetTab] }, terminalLayoutsByTabId, ...(session.remoteSessionIdsByTabId ? { remoteSessionIdsByTabId } : {}), - // A stale copy's incarnation belongs to the PTY it no longer names. terminalPtyIncarnationsByPaneKey: moveRecordKey( session.terminalPtyIncarnationsByPaneKey, fromPaneKey, - boundHere && boundHere !== ptyId ? null : toPaneKey + toPaneKey ), sleepingAgentSessionsByPaneKey: moveRecordKey( session.sleepingAgentSessionsByPaneKey, @@ -138,62 +132,47 @@ function moveLeafInPartition( } /** - * Moves one leaf and its binding into a new tab in a single session write (STA-9259). The leaf - * id and the PTY are kept; only the tab half of the pane key changes, so every pane-keyed record - * follows it here instead of being rebuilt later by a renderer save that main's membership rebase - * would discard. Every owner partition holding the pane moves together: a relay reattach writes an - * SSH pane into `local` as well as `ssh:`, and a copy left behind refuses the moved pane's bind. + * Moves one leaf and its binding into a new tab in the worktree's home partition, in a single + * session write (STA-9259). The leaf id and the PTY are kept; only the tab half of the pane key + * changes, so every pane-keyed record follows it in this write; a window save carries presentation + * only and never rebuilds them. `session` is null when nothing changes. */ export function planTerminalLeafMove( - partitions: readonly TerminalSessionPartition[], + home: WorkspaceSessionState, request: TerminalLeafMoveRequest ): PlannedTerminalLeafMove { const { worktreeId, sourceTabId, leafId } = request const refuse = ( reason: Extract['reason'] - ): PlannedTerminalLeafMove => ({ result: { status: 'refused', reason }, sessions: [] }) - const owners = partitions.filter(({ hostId }) => isTerminalOwnerPartition(hostId)) - if (partitions.some(({ session }) => hasTabId(session, request.targetTabId))) { + ): PlannedTerminalLeafMove => ({ result: { status: 'refused', reason }, session: null }) + if (hasTabId(home, request.targetTabId)) { return refuse('target_tab_exists') } // A layout left behind by a removed tab row owns nothing. - const leafElsewhere = owners.some(({ session }) => { - const tabIds = liveTabIds(session) - return Object.entries(session.terminalLayoutsByTabId ?? {}).some( - ([tabId, layout]) => - tabId !== sourceTabId && - tabIds.has(tabId) && - layoutContainsLeafId(layout?.root ?? null, leafId) - ) - }) + const tabIds = liveTabIds(home) + const leafElsewhere = Object.entries(home.terminalLayoutsByTabId ?? {}).some( + ([tabId, layout]) => + tabId !== sourceTabId && + tabIds.has(tabId) && + layoutContainsLeafId(layout?.root ?? null, leafId) + ) if (leafElsewhere) { return refuse('leaf_in_other_tab') } - const holders = owners.flatMap(({ hostId, session }) => { - const sourceTab = session.tabsByWorktree?.[worktreeId]?.find((tab) => tab.id === sourceTabId) - const sourceLayout = session.terminalLayoutsByTabId?.[sourceTabId] - return sourceTab && sourceLayout && layoutContainsLeafId(sourceLayout.root, leafId) - ? [{ hostId, session, sourceTab, sourceLayout }] - : [] - }) + const sourceTab = home.tabsByWorktree?.[worktreeId]?.find((tab) => tab.id === sourceTabId) + const sourceLayout = home.terminalLayoutsByTabId?.[sourceTabId] // Main never saw this leaf, so no binding of it can be duplicated here. - if (holders.length === 0) { - return { result: { status: 'not_held' }, sessions: [] } + if (!sourceTab || !sourceLayout || !layoutContainsLeafId(sourceLayout.root, leafId)) { + return { result: { status: 'not_held' }, session: null } } - const boundPtyIds = new Set( - holders.flatMap(({ sourceLayout }) => sourceLayout.ptyIdsByLeafId?.[leafId] ?? []) - ) - // The renderer's live PTY id wins: after an SSH respawn one copy can still name the old PTY. - const ptyId = request.ptyId ?? (boundPtyIds.size === 1 ? [...boundPtyIds][0] : null) - if (boundPtyIds.size > 0 && !(ptyId && boundPtyIds.has(ptyId))) { + const boundPtyId = sourceLayout.ptyIdsByLeafId?.[leafId] + const ptyId = request.ptyId ?? boundPtyId ?? null + if (boundPtyId && ptyId !== boundPtyId) { return refuse('pty_mismatch') } return { result: { status: 'moved', ptyId }, - sessions: holders.map((holder) => ({ - hostId: holder.hostId, - session: moveLeafInPartition(holder, request, ptyId) - })) + session: moveLeafInPartition({ session: home, sourceTab, sourceLayout }, request, ptyId) } } diff --git a/src/main/persistence/terminal-topology/terminal-local-residue-homing.ts b/src/main/persistence/terminal-topology/terminal-local-residue-homing.ts new file mode 100644 index 000000000000..fab88bbf4ec3 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-local-residue-homing.ts @@ -0,0 +1,53 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + adoptStrandedHostPartitionSession, + withoutWorkspaces, + workspaceIdsNamedByPartition +} from '../../../shared/workspace-session-stranded-partition-adoption' +import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../shared/workspace-scope' +import type { Store } from '../loading-store/store' +import { isTerminalOwnerPartition } from './terminal-topology-membership' + +type ResidueStore = Pick + +/** + * Before a window save drops the rows of a workspace another partition homes, rows an older build + * left here, main moves them to that home when it holds none, as its save of the window's routed + * session used to (#19572, #26098). + */ +export function homeStrandedRowsBeforeWindowSave( + store: ResidueStore, + hostId: ExecutionHostId, + homeHostIdOf: (worktreeId: string) => ExecutionHostId | null +): void { + if (!isTerminalOwnerPartition(hostId)) { + return + } + const strandedByHome = new Map>() + for (const [worktreeId, tabs] of Object.entries( + store.getWorkspaceSession(hostId).tabsByWorktree + )) { + const home = homeHostIdOf(worktreeId) + if ( + tabs.length > 0 && + home && + home !== hostId && + isTerminalOwnerPartition(home) && + (store.getWorkspaceSession(home).tabsByWorktree[worktreeId]?.length ?? 0) === 0 + ) { + const stranded = strandedByHome.get(home) ?? new Set() + stranded.add(normalizeWorkspaceSessionKeyToWorkspaceId(worktreeId)) + strandedByHome.set(home, stranded) + } + } + for (const [home, workspaceIds] of strandedByHome) { + const residue = store.getWorkspaceSession(hostId) + const others = workspaceIdsNamedByPartition(residue) + workspaceIds.forEach((workspaceId) => others.delete(workspaceId)) + const homed = adoptStrandedHostPartitionSession(store.getWorkspaceSession(home), residue, { + foreignSessionKeys: others + }) + store.setWorkspaceSession(homed.session, home) + store.setWorkspaceSession(withoutWorkspaces(residue, workspaceIds), hostId) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts index fad25738ea31..8d54c1b9a436 100644 --- a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts +++ b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts @@ -1,15 +1,16 @@ -import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { TerminalTab } from '../../../shared/terminal-tab-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' +import { + isTerminalOwnerPartition, + type TerminalSessionPartition +} from './terminal-topology-membership' // The two terminal-layout invariants: a terminal is bound to at most one leaf, and a leaf id is in // at most one tab. The binding write reports breaches; nothing removes them yet. -export type TerminalSessionPartition = { hostId: ExecutionHostId; session: WorkspaceSessionState } - export type TerminalLeafOwner = { hostId: ExecutionHostId worktreeId: string @@ -27,12 +28,6 @@ export type TerminalOwnerConflictReason = // Kept apart: older relay reattaches left SSH panes in `local`, so this may be one moved surface. | 'leaf_in_other_tab_on_other_host' -/** `runtime:` partitions belong to a remote Orca server and are written only by its tab sync. */ -export function isTerminalOwnerPartition(hostId: ExecutionHostId): boolean { - const kind = parseExecutionHostId(hostId)?.kind - return kind === 'local' || kind === 'ssh' -} - /** Every leaf of every tab row; a layout left behind by a removed tab row owns nothing. */ export function collectTerminalLeafOwners({ hostId, diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts index 3376859499e2..b4e489364b76 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts @@ -14,11 +14,10 @@ export type TerminalPanePlacementAgreement = | 'tab_missing' | 'parent_missing' | 'root_occupied' - | 'check_threw' /** - * Whether placement names the tab today's binding write picks, read before that write. Report-only - * until the mint and graft are deleted; the write never branches on it. + * Whether placement names the tab's current shape, read before the binding write. The write + * applies placement only on `agrees`; every other verdict keeps the mint and graft. */ export function terminalPanePlacementAgreement( placement: TerminalPanePlacement | undefined, diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts new file mode 100644 index 000000000000..7f701ac7da6c --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + parseTerminalPanePlacement, + type TerminalPanePlacement +} from '../../../shared/terminal-pane-placement' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { applyPtyBinding } from '../loading-store/pty-binding-session-update' +import type { PersistPtyBindingArgs } from '../loading-store/pty-binding-persistence' + +const WORKTREE = 'repo::/fixture' +const A = '11111111-1111-4111-8111-111111111111' +const B = '22222222-2222-4222-8222-222222222222' +const C = '33333333-3333-4333-8333-333333333333' +const NEW = '44444444-4444-4444-8444-444444444444' +const NOW = 1_800_000_000_000 + +const leaf = (leafId: string): TerminalPaneLayoutNode => ({ type: 'leaf', leafId }) +const split = ( + direction: 'horizontal' | 'vertical', + first: TerminalPaneLayoutNode, + second: TerminalPaneLayoutNode, + ratio?: number +): TerminalPaneLayoutNode => ({ + type: 'split', + direction, + first, + second, + ...(ratio !== undefined ? { ratio } : {}) +}) + +function tab(id: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId: WORKTREE, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session(root?: TerminalPaneLayoutNode | null): WorkspaceSessionState { + return { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: root === undefined ? {} : { [WORKTREE]: [tab('tab')] }, + terminalLayoutsByTabId: + root === undefined + ? {} + : { tab: { root, activeLeafId: null, expandedLeafId: null, ptyIdsByLeafId: {} } } + } +} + +function bind( + start: WorkspaceSessionState, + placement?: TerminalPanePlacement, + extra: Partial = {} +): WorkspaceSessionState { + const next = structuredClone(start) + const args: PersistPtyBindingArgs = { + worktreeId: WORKTREE, + tabId: 'tab', + leafId: NEW, + ptyId: 'pty-new', + startupCwd: '/fixture/sub', + ...extra, + ...(placement ? { placement } : {}) + } + applyPtyBinding(args, next, WORKTREE, `tab:${NEW}`) + return next +} + +const rootOf = (state: WorkspaceSessionState): TerminalPaneLayoutNode | null | undefined => + state.terminalLayoutsByTabId.tab?.root + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('new-tab placement', () => { + it('builds the row from the minted row plus only the fields placement carries', () => { + const minted = bind(session()) + const row = { + title: 'build', + defaultTitle: 'Terminal 3', + customTitle: 'Build', + color: '#f97316', + createdAt: 7, + startupCwd: '/fixture/build', + shellOverride: 'zsh', + quickCommandLabel: 'pnpm dev', + launchAgent: 'claude' as const, + viewMode: 'chat' as const, + forceHostRuntime: true + } + const placed = bind(session(), { kind: 'new-tab', row }) + expect(placed.tabsByWorktree[WORKTREE]).toEqual([ + { ...minted.tabsByWorktree[WORKTREE]?.[0], ...row } + ]) + expect(placed.tabsByWorktree[WORKTREE]?.[0]?.pendingActivationSpawn).toBe(true) + const { tabsByWorktree: _placedTabs, ...placedRest } = placed + const { tabsByWorktree: _mintedTabs, ...mintedRest } = minted + expect(placedRest).toEqual(mintedRest) + }) + + it('keeps a minted field the row leaves undefined', () => { + const placed = bind(session(), { + kind: 'new-tab', + row: { title: 'build', customTitle: undefined } + }) + expect(placed.tabsByWorktree[WORKTREE]?.[0]).toMatchObject({ + title: 'build', + customTitle: null + }) + }) + + it("takes startupCwd only from the row, never from the spawn's cwd", () => { + const placed = bind(session(), { kind: 'new-tab', row: { title: 'build' } }) + expect(placed.tabsByWorktree[WORKTREE]?.[0]).not.toHaveProperty('startupCwd') + const withCwd = bind(session(), { kind: 'new-tab', row: { startupCwd: '/fixture/build' } }) + expect(withCwd.tabsByWorktree[WORKTREE]?.[0]?.startupCwd).toBe('/fixture/build') + expect(bind(session()).tabsByWorktree[WORKTREE]?.[0]?.startupCwd).toBe('/fixture/sub') + }) + + it('is ignored when the tab already exists, and for a leaf already present', () => { + const row = { title: 'other', color: '#000000' } + const existing = session(leaf(A)) + expect(bind(existing, { kind: 'new-tab', row })).toEqual(bind(existing)) + const present = session(leaf(NEW)) + expect(bind(present, { kind: 'new-tab', row })).toEqual(bind(present)) + }) + + it('without a row writes the minted tab', () => { + expect(bind(session(), { kind: 'new-tab' })).toEqual(bind(session())) + }) +}) + +describe('split placement', () => { + it('inserts at the parent leaf with direction and ratio', () => { + const start = session(split('vertical', leaf(A), leaf(B))) + const placed = bind(start, { + kind: 'split', + parentLeafId: B, + direction: 'horizontal', + ratio: 0.3 + }) + expect(rootOf(placed)).toEqual( + split('vertical', leaf(A), split('horizontal', leaf(B), leaf(NEW), 0.3)) + ) + expect(placed.terminalLayoutsByTabId.tab).toMatchObject({ + activeLeafId: NEW, + ptyIdsByLeafId: { [NEW]: 'pty-new' } + }) + }) + + it('keeps a nested before-split proposed tree verbatim', () => { + const start = session(split('vertical', split('horizontal', leaf(A), leaf(B), 0.4), leaf(C))) + const proposedRoot = split( + 'vertical', + split('horizontal', leaf(A), split('vertical', leaf(NEW), leaf(B)), 0.4), + leaf(C), + 0.6 + ) + const placed = bind(start, { + kind: 'split', + parentLeafId: B, + direction: 'vertical', + proposedRoot + }) + expect(rootOf(placed)).toEqual(proposedRoot) + }) + + it('keeps a proposed tree that splits a subtree', () => { + const start = session(split('vertical', leaf(A), leaf(B))) + const proposedRoot = split('horizontal', split('vertical', leaf(A), leaf(B)), leaf(NEW)) + expect( + rootOf(bind(start, { kind: 'split', parentLeafId: B, direction: 'horizontal', proposedRoot })) + ).toEqual(proposedRoot) + }) + + it.each([ + ['misses a current leaf', split('vertical', leaf(A), leaf(NEW))], + ['lacks the new leaf', split('vertical', leaf(A), leaf(B))], + [ + 'adds a foreign leaf', + split('vertical', split('vertical', leaf(A), leaf(B)), split('vertical', leaf(NEW), leaf(C))) + ], + [ + 'repeats a leaf', + split('vertical', split('vertical', leaf(A), leaf(B)), split('vertical', leaf(NEW), leaf(A))) + ] + ])('falls back to the parent split when the proposed tree %s', (_name, proposedRoot) => { + const start = session(split('vertical', leaf(A), leaf(B))) + const placed = bind(start, { + kind: 'split', + parentLeafId: A, + direction: 'horizontal', + proposedRoot + }) + expect(rootOf(placed)).toEqual( + split('vertical', split('horizontal', leaf(A), leaf(NEW)), leaf(B)) + ) + }) + + it('keeps today’s root graft when the parent is not in the tab', () => { + const start = session(split('vertical', leaf(A), leaf(B))) + const proposedRoot = split('vertical', leaf(NEW), split('vertical', leaf(A), leaf(B))) + expect( + bind(start, { kind: 'split', parentLeafId: C, direction: 'horizontal', proposedRoot }) + ).toEqual(bind(start)) + expect(rootOf(bind(start))).toEqual( + split('vertical', split('vertical', leaf(A), leaf(B)), leaf(NEW)) + ) + }) + + it('keeps today’s mint when the tab does not exist', () => { + expect(bind(session(), { kind: 'split', parentLeafId: A, direction: 'horizontal' })).toEqual( + bind(session()) + ) + }) + + it('is ignored for a leaf already present', () => { + const start = session(split('vertical', leaf(A), leaf(NEW))) + const proposedRoot = split('horizontal', leaf(NEW), leaf(A)) + expect( + bind(start, { kind: 'split', parentLeafId: A, direction: 'horizontal', proposedRoot }) + ).toEqual(bind(start)) + }) + + it('bumps the topology revision exactly as the graft does', () => { + const start = { ...session(leaf(A)), terminalTopologyRevisionByRepoId: { repo: 2 } } + const placed = bind(start, { kind: 'split', parentLeafId: A, direction: 'horizontal' }) + expect(placed.terminalTopologyRevisionByRepoId).toEqual( + bind(start).terminalTopologyRevisionByRepoId + ) + }) +}) + +describe('root and malformed placement', () => { + it('root placement writes what an empty layout gets today', () => { + const empty = session(null) + expect(bind(empty, { kind: 'root' })).toEqual(bind(empty)) + expect(rootOf(bind(empty, { kind: 'root' }))).toEqual(leaf(NEW)) + const occupied = session(leaf(A)) + expect(bind(occupied, { kind: 'root' })).toEqual(bind(occupied)) + }) + + it.each([ + { kind: 'split', parentLeafId: 'not-a-leaf', direction: 'vertical' }, + { kind: 'split', parentLeafId: A, direction: 'diagonal' }, + { kind: 'teleport' }, + 'new-tab' + ])('a malformed placement parses to none and writes today’s state: %j', (raw) => { + const placement = parseTerminalPanePlacement(raw) ?? undefined + expect(placement).toBeUndefined() + const start = session(leaf(A)) + expect(bind(start, placement)).toEqual(bind(start)) + }) + + it('drops only a malformed proposed tree, then splits at the parent', () => { + const placement = parseTerminalPanePlacement({ + kind: 'split', + parentLeafId: A, + direction: 'horizontal', + proposedRoot: { type: 'split', first: leaf(A) } + }) + expect(rootOf(bind(session(leaf(A)), placement ?? undefined))).toEqual( + split('horizontal', leaf(A), leaf(NEW)) + ) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts new file mode 100644 index 000000000000..517b9f2e43ea --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts @@ -0,0 +1,149 @@ +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' +import type { + TerminalPaneLayoutNode, + TerminalPaneSplitDirection, + TerminalTab +} from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { omitUndefinedValues } from '../../../shared/rpc-contract/ui-update-value-tolerance-params' +import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { + cloneLayoutNode, + layoutContainsLeafId +} from '../restoring-sessions/terminal-layout-normalization' +import { sameTerminalLeafSet } from './terminal-layout-set' + +type SplitPlacement = Extract + +export type TerminalPaneToPlace = { + worktreeId: string + tabId: string + /** Absent for a tab created before its first pane. */ + leafId?: string + startupCwd?: string +} + +/** + * Records a new tab row and leaf, unbound, where `placement` puts them: the creation half of both + * the window's creation commit and a spawn's binding. Mutates `session`; true when membership changed. + */ +export function placeTerminalPane( + session: WorkspaceSessionState, + { worktreeId, tabId, leafId, startupCwd }: TerminalPaneToPlace, + placement: TerminalPanePlacement | undefined +): boolean { + let changed = false + const tabs = session.tabsByWorktree?.[worktreeId] + if (!tabs?.some((tab) => tab.id === tabId)) { + changed = true + const minted = createMinimalPersistedTerminalTab({ + worktreeId, + tabId, + ptyId: null, + existingTabCount: tabs?.length ?? 0, + ...(startupCwd ? { startupCwd } : {}) + }) + session.tabsByWorktree = { + ...session.tabsByWorktree, + [worktreeId]: [...(tabs ?? []), placedTerminalTab(minted, placement)] + } + session.activeWorktreeId ??= worktreeId + session.activeTabId ??= tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [worktreeId]: session.activeTabIdByWorktree?.[worktreeId] ?? tabId + } + } + if (leafId === undefined || !isTerminalLeafId(leafId)) { + return changed + } + const layout = session.terminalLayoutsByTabId?.[tabId] + if (!layout) { + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [tabId]: { root: { type: 'leaf', leafId }, activeLeafId: leafId, expandedLeafId: null } + } + return true + } + if (!layout.root) { + layout.root = { type: 'leaf', leafId } + layout.activeLeafId = leafId + layout.expandedLeafId = null + return true + } + if (layoutContainsLeafId(layout.root, leafId)) { + return changed + } + // A sender without placement gets a minimal leaf at the root, so a crash can't strand its pane. + layout.root = + placement?.kind === 'split' + ? placedSplitRoot(layout.root, leafId, placement) + : { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId } + } + layout.activeLeafId = leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + return true +} + +/** + * The minted row with the sender's creation fields over it; a field it omits keeps the minted + * value, except `startupCwd`, which is the window's to set and never the spawn's cwd. + */ +export function placedTerminalTab( + minted: TerminalTab, + placement: TerminalPanePlacement | undefined +): TerminalTab { + if (placement?.kind !== 'new-tab' || !placement.row) { + return minted + } + const { startupCwd: _spawnCwd, ...mintedWithoutCwd } = minted + return { ...mintedWithoutCwd, ...omitUndefinedValues(placement.row) } +} + +/** + * The root once `leafId` joins it: the sender's tree when it holds exactly today's leaves plus + * this one (the only way to say before/after or split a subtree), else a split at the parent. + */ +export function placedSplitRoot( + root: TerminalPaneLayoutNode, + leafId: string, + { parentLeafId, direction, ratio, proposedRoot }: SplitPlacement +): TerminalPaneLayoutNode { + const split = splitLayoutLeaf(root, parentLeafId, leafId, direction, ratio) + return proposedRoot && sameTerminalLeafSet(proposedRoot, split) + ? cloneLayoutNode(proposedRoot) + : split +} + +/** Replaces `parentLeafId` with a split whose second child is the new leaf. */ +export function splitLayoutLeaf( + node: TerminalPaneLayoutNode, + parentLeafId: string, + leafId: string, + direction: TerminalPaneSplitDirection, + ratio?: number +): TerminalPaneLayoutNode { + if (node.type === 'split') { + return { + ...node, + first: splitLayoutLeaf(node.first, parentLeafId, leafId, direction, ratio), + second: splitLayoutLeaf(node.second, parentLeafId, leafId, direction, ratio) + } + } + return node.leafId === parentLeafId + ? { + type: 'split', + direction, + first: node, + second: { type: 'leaf', leafId }, + ...(ratio !== undefined ? { ratio } : {}) + } + : node +} diff --git a/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts b/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts new file mode 100644 index 000000000000..839061e226ef --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' +import { Store } from '../loading-store/store' +import { importPeerTopology } from './terminal-topology-commit' + +const HOST_ID = 'ssh:target-1' +const WORKTREE_ID = 'ssh-repo::/srv/app' +const LEAF = '11111111-1111-4111-8111-111111111111' + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openStore(): Store { + const directory = mkdtempSync(join(tmpdir(), 'terminal-peer-import-')) + directories.push(directory) + return createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') }) +} + +function tab(id: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function leafLayout(tabId: string): WorkspaceSessionState['terminalLayoutsByTabId'][string] { + return { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: `pty-${tabId}` } + } +} + +function mainSession(revision: number): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('main-tab')] }, + terminalLayoutsByTabId: { 'main-tab': leafLayout('main-tab') }, + terminalPtyIncarnationsByPaneKey: { [`main-tab:${LEAF}`]: 'main-incarnation' }, + terminalTopologyRevisionByRepoId: { 'ssh-repo': revision } + } +} + +function importHostPull(store: Store): WorkspaceSessionState { + importPeerTopology( + store, + 'target-1', + { + tabsByWorktree: { [WORKTREE_ID]: [tab('host-tab')] }, + terminalLayoutsByTabId: { 'host-tab': leafLayout('host-tab') }, + terminalPtyIncarnationsByPaneKey: { [`host-tab:${LEAF}`]: 'host-incarnation' } + }, + () => false + ) + return store.getWorkspaceSession(HOST_ID) +} + +describe('a host pull into an SSH partition main has published nothing since', () => { + // As main's membership rebase did: once main fenced the repo, its tabs and panes stand. + it("keeps main's tabs and pane records in a repo main has fenced, dropping host-only tabs", () => { + const store = openStore() + store.setWorkspaceSession(mainSession(1), HOST_ID) + + const saved = importHostPull(store) + + expect(saved.tabsByWorktree[WORKTREE_ID]?.map((row) => row.id)).toEqual(['main-tab']) + expect(Object.keys(saved.terminalLayoutsByTabId)).toEqual(['main-tab']) + expect(saved.terminalPtyIncarnationsByPaneKey).toEqual({ + [`main-tab:${LEAF}`]: 'main-incarnation' + }) + expect(saved.terminalTopologyRevisionByRepoId).toEqual({ 'ssh-repo': 1 }) + }) + + it("replaces main's rows and the pane records the pull names in a repo main never fenced", () => { + const store = openStore() + store.setWorkspaceSession(mainSession(0), HOST_ID) + + const saved = importHostPull(store) + + expect(saved.tabsByWorktree[WORKTREE_ID]?.map((row) => row.id)).toEqual(['host-tab']) + expect(Object.keys(saved.terminalLayoutsByTabId)).toEqual(['host-tab']) + expect(saved.terminalPtyIncarnationsByPaneKey).toEqual({ + [`host-tab:${LEAF}`]: 'host-incarnation' + }) + }) + + // Two desktops on one SSH host: a tab this window created starts no fence, so the other's imports. + it("imports another desktop's new tab after this window created its own", async () => { + const store = openStore() + await store.createTerminalSurface( + { + worktreeId: WORKTREE_ID, + tabId: 'main-tab', + leafId: LEAF, + placement: { kind: 'new-tab', row: { title: 'Terminal', createdAt: 1 } } + }, + HOST_ID + ) + expect( + store.getWorkspaceSession(HOST_ID).tabsByWorktree[WORKTREE_ID]?.map((row) => row.id) + ).toEqual(['main-tab']) + + importPeerTopology( + store, + 'target-1', + { + tabsByWorktree: { [WORKTREE_ID]: [tab('main-tab'), tab('host-tab')] }, + terminalLayoutsByTabId: { + 'main-tab': leafLayout('main-tab'), + 'host-tab': leafLayout('host-tab') + } + }, + () => false + ) + + const saved = store.getWorkspaceSession(HOST_ID) + expect(saved.tabsByWorktree[WORKTREE_ID]?.map((row) => row.id)).toEqual([ + 'main-tab', + 'host-tab' + ]) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts b/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts new file mode 100644 index 000000000000..f3aa8addb1b7 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts @@ -0,0 +1,79 @@ +import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { hasHostAuthoritativeTerminalMembership } from './terminal-topology-membership' + +/** + * A pull's rows over main's. Main's tabs, their layouts and pane incarnations stand on a worktree + * main has published past what the window merged against, where the pull adds only tabs main + * neither holds nor closed, and in a repo main has fenced, where it adds none (as main's + * membership rebase did). + */ +export function overNewerMainRows( + pull: WorkspaceSessionPatch, + main: WorkspaceSessionState, + publishedSince: (worktreeId: string) => boolean +): WorkspaceSessionPatch { + if (!pull.tabsByWorktree) { + return pull + } + const tabsByWorktree = { ...pull.tabsByWorktree } + const layouts = { ...pull.terminalLayoutsByTabId } + // Tabs whose layout and incarnations are main's, and pulled tabs main's membership drops. + const mainTabIds = new Set() + const droppedTabIds = new Set() + const worktreeIds = new Set([ + ...Object.keys(main.tabsByWorktree), + ...Object.keys(pull.tabsByWorktree) + ]) + for (const worktreeId of worktreeIds) { + const addsHostTabs = publishedSince(worktreeId) + if (!addsHostTabs && !hasHostAuthoritativeTerminalMembership(main, worktreeId)) { + continue + } + const mainTabs = main.tabsByWorktree[worktreeId] ?? [] + const held = new Set(mainTabs.map((tab) => tab.id)) + const hostOnly = (pull.tabsByWorktree[worktreeId] ?? []).filter((tab) => !held.has(tab.id)) + const added = addsHostTabs + ? hostOnly.filter( + (tab) => + !hasClosedTerminalTabRecord(main.closedTerminalTabTombstonesByTabId, tab.id, worktreeId) + ) + : [] + for (const tab of hostOnly) { + if (!added.includes(tab)) { + droppedTabIds.add(tab.id) + delete layouts[tab.id] + } + } + for (const tab of mainTabs) { + mainTabIds.add(tab.id) + const layout = main.terminalLayoutsByTabId[tab.id] + if (layout) { + layouts[tab.id] = layout + } else { + delete layouts[tab.id] + } + } + tabsByWorktree[worktreeId] = [...mainTabs, ...added] + } + const tabOf = ([paneKey]: [string, string]): string => parsePaneKey(paneKey)?.tabId ?? '' + return { + ...pull, + tabsByWorktree, + ...(pull.terminalLayoutsByTabId && { terminalLayoutsByTabId: layouts }), + ...(pull.terminalPtyIncarnationsByPaneKey && { + terminalPtyIncarnationsByPaneKey: Object.fromEntries([ + ...Object.entries(pull.terminalPtyIncarnationsByPaneKey).filter( + (entry) => !mainTabIds.has(tabOf(entry)) && !droppedTabIds.has(tabOf(entry)) + ), + ...Object.entries(main.terminalPtyIncarnationsByPaneKey ?? {}).filter((entry) => + mainTabIds.has(tabOf(entry)) + ) + ]) + }) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts new file mode 100644 index 000000000000..8c4789ece653 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { Tab } from '../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' +import { + mergeRendererPresentationSave, + patchRendererSession +} from './terminal-renderer-presentation-save' +import { clearLaunchAgent } from './terminal-topology-commit' + +const WORKTREE = 'repo::/worktree' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' +const RUNTIME_HOST = 'runtime:env-1' + +function row(id: string, overrides: Partial = {}): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId: WORKTREE, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function unified(id: string, contentType: Tab['contentType'] = 'terminal'): Tab { + return { + id, + entityId: id, + groupId: 'group', + worktreeId: WORKTREE, + contentType, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function sleeping(paneKey: string, capturedAt: number): SleepingAgentSessionRecord { + return { + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${capturedAt}` }, + prompt: 'finish', + state: 'waiting', + capturedAt, + updatedAt: capturedAt, + origin: 'worktree-sleep' + } +} + +function splitRoot(ratio: number): TerminalLayoutSnapshot['root'] { + return { + type: 'split', + direction: 'vertical', + ratio, + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + } +} + +const splitLayout: TerminalLayoutSnapshot = { + root: splitRoot(0.3), + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: 'pty-left', [RIGHT]: 'pty-right' } +} + +/** What main holds: one split tab, its sleeping agent, a close record and fences. */ +function mainSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE]: [row('tab', { launchAgent: 'codex', ptyId: 'pty-left' })] }, + terminalLayoutsByTabId: { tab: splitLayout }, + unifiedTabs: { [WORKTREE]: [unified('tab')] }, + tabGroups: { + [WORKTREE]: [{ id: 'group', worktreeId: WORKTREE, activeTabId: 'tab', tabOrder: ['tab'] }] + }, + activeTabIdByWorktree: { [WORKTREE]: 'tab' }, + sleepingAgentSessionsByPaneKey: { [`tab:${LEFT}`]: sleeping(`tab:${LEFT}`, 1) }, + terminalPtyIncarnationsByPaneKey: { [`tab:${LEFT}`]: 'incarnation-left' }, + closedTerminalTabTombstonesByTabId: { + closed: { worktreeId: WORKTREE, closedAt: 5 } + }, + terminalTopologyRevisionByRepoId: { repo: 3 }, + defaultTerminalTabsAppliedByWorktreeId: { [WORKTREE]: true } + } +} + +/** Main's own authority: what the slice publishes plus the records no slice carries. */ +function mainAuthority(session: WorkspaceSessionState) { + return { + slice: projectTerminalTopologySlice(session, 'local', WORKTREE), + incarnations: session.terminalPtyIncarnationsByPaneKey, + closed: session.closedTerminalTabTombstonesByTabId, + defaults: session.defaultTerminalTabsAppliedByWorktreeId + } +} + +describe('mergeRendererPresentationSave', () => { + it('a stale or extra tab, layout, binding or sleeping record changes nothing main authors', () => { + const prior = mainSession() + const stale: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE]: [ + // The window's ptyId is its live attachment, not main's binding. + row('tab', { ptyId: 'pty-window', launchAgent: 'codex' }), + row('extra') + ] + }, + terminalLayoutsByTabId: { + tab: { + ...splitLayout, + root: splitRoot(0.9), + ptyIdsByLeafId: { [LEFT]: 'pty-window' } + }, + extra: { root: { type: 'leaf', leafId: RIGHT }, activeLeafId: RIGHT, expandedLeafId: null } + }, + sleepingAgentSessionsByPaneKey: { [`extra:${RIGHT}`]: sleeping(`extra:${RIGHT}`, 2) }, + unifiedTabs: { [WORKTREE]: [unified('tab'), unified('extra')] }, + tabGroups: { + [WORKTREE]: [ + { id: 'group', worktreeId: WORKTREE, activeTabId: 'extra', tabOrder: ['tab', 'extra'] } + ] + }, + activeTabIdByWorktree: { [WORKTREE]: 'extra' } + } + + const saved = mergeRendererPresentationSave(stale, prior, 'local') + + expect(mainAuthority(saved)).toEqual(mainAuthority(prior)) + expect(saved.sleepingAgentSessionsByPaneKey).toEqual(prior.sleepingAgentSessionsByPaneKey) + expect(saved.terminalTopologyRevisionByRepoId).toEqual({ repo: 3 }) + expect(saved.unifiedTabs?.[WORKTREE]?.map((tab) => tab.id)).toEqual(['tab']) + expect(saved.tabGroups?.[WORKTREE]).toEqual([ + { id: 'group', worktreeId: WORKTREE, activeTabId: 'tab', tabOrder: ['tab'] } + ]) + expect(saved.activeTabIdByWorktree?.[WORKTREE]).toBe('tab') + }) + + it('takes presentation per tab and per leaf, on the panes main holds', () => { + const prior = mainSession() + const window: WorkspaceSessionState = { + ...prior, + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: { + [WORKTREE]: [ + row('tab', { title: 'npm test', customTitle: 'Tests', color: 'red', sortOrder: 4 }) + ] + }, + terminalLayoutsByTabId: { + tab: { + ...splitLayout, + activeLeafId: RIGHT, + expandedLeafId: RIGHT, + buffersByLeafId: { [RIGHT]: 'scrollback' }, + titlesByLeafId: { [LEFT]: 'Server' } + } + } + } + + const saved = mergeRendererPresentationSave(window, prior, 'local') + + expect(saved.tabsByWorktree[WORKTREE]).toEqual([ + row('tab', { + launchAgent: 'codex', + ptyId: 'pty-left', + title: 'npm test', + customTitle: 'Tests', + color: 'red', + sortOrder: 4 + }) + ]) + expect(saved.terminalLayoutsByTabId.tab).toEqual({ + ...splitLayout, + activeLeafId: RIGHT, + expandedLeafId: RIGHT, + buffersByLeafId: { [RIGHT]: 'scrollback' }, + titlesByLeafId: { [LEFT]: 'Server' } + }) + expect(saved.unifiedTabs).toEqual(prior.unifiedTabs) + }) + + it("keeps main's scrollback for a pane the window has not attached yet", () => { + const prior = mainSession() + prior.terminalLayoutsByTabId.tab = { + ...splitLayout, + scrollbackRefsByLeafId: { [LEFT]: 'ref-left', [RIGHT]: 'ref-right' }, + buffersByLeafId: { [LEFT]: 'left-buffer' } + } + // A snapshot from before the left pane respawned; the right one is attached and saves its own. + const window: WorkspaceSessionState = { + ...prior, + terminalLayoutsByTabId: { + tab: { + ...splitLayout, + ptyIdsByLeafId: { [RIGHT]: 'pty-right' }, + buffersByLeafId: { [RIGHT]: 'right-buffer' } + } + } + } + + const saved = mergeRendererPresentationSave(window, prior, 'local') + + expect(saved.terminalLayoutsByTabId.tab).toEqual({ + ...splitLayout, + scrollbackRefsByLeafId: { [LEFT]: 'ref-left' }, + buffersByLeafId: { [LEFT]: 'left-buffer', [RIGHT]: 'right-buffer' } + }) + }) + + it("a save cannot change a tab's creation fields", () => { + const prior = mainSession() + prior.tabsByWorktree[WORKTREE] = [ + row('tab', { launchAgent: 'codex', ptyId: 'pty-left', shellOverride: 'zsh' }) + ] + const window: WorkspaceSessionState = { + ...prior, + tabsByWorktree: { + [WORKTREE]: [ + row('tab', { launchAgent: 'claude', startupCwd: '/elsewhere', customTitle: 'Mine' }) + ] + } + } + + const saved = mergeRendererPresentationSave(window, prior, 'local') + + expect(saved.tabsByWorktree[WORKTREE]).toEqual([ + row('tab', { + launchAgent: 'codex', + ptyId: 'pty-left', + shellOverride: 'zsh', + customTitle: 'Mine' + }) + ]) + }) + + it('a push landing between clearing the launched agent and the save does not bring it back', () => { + let session = mainSession() + const store = { + getWorkspaceSession: () => session, + patchWorkspaceSession: (patch: Partial) => { + session = { ...session, ...patch } + } + } + // The window clears the agent and tells main; a push sent before main's clear then puts it back. + clearLaunchAgent(store, { worktreeId: WORKTREE, tabId: 'tab' }, 'local') + const resurrected = row('tab', { launchAgent: 'codex', ptyId: 'pty-left' }) + + patchRendererSession( + { ...store, setWorkspaceSession: () => {}, stageWorkspaceSessionBeforeUnload: () => {} }, + { tabsByWorktree: { [WORKTREE]: [resurrected] } } + ) + + expect(session.tabsByWorktree[WORKTREE]?.[0]).not.toHaveProperty('launchAgent') + expect(projectTerminalTopologySlice(session, 'local', WORKTREE).tabs[0]).not.toHaveProperty( + 'launchAgent' + ) + }) + + it('a patch carries only the fields it names', () => { + const prior = mainSession() + const patches: Partial[] = [] + patchRendererSession( + { + getWorkspaceSession: () => prior, + patchWorkspaceSession: (patch) => patches.push(patch), + setWorkspaceSession: () => {}, + stageWorkspaceSessionBeforeUnload: () => {} + }, + { activeWorktreeId: WORKTREE, sleepingAgentSessionsByPaneKey: {} } + ) + expect(patches).toEqual([ + { + activeWorktreeId: WORKTREE, + sleepingAgentSessionsByPaneKey: prior.sleepingAgentSessionsByPaneKey + } + ]) + }) + + it('a layout naming other panes is stale, so main keeps its whole layout', () => { + const prior = mainSession() + const saved = mergeRendererPresentationSave( + { + ...prior, + terminalLayoutsByTabId: { + tab: { + root: { type: 'leaf', leafId: LEFT }, + activeLeafId: LEFT, + expandedLeafId: null, + titlesByLeafId: { [LEFT]: 'Server' } + } + } + }, + prior, + 'local' + ) + expect(saved.terminalLayoutsByTabId.tab).toBe(prior.terminalLayoutsByTabId.tab) + }) + + it('restores the tab bar entry of a tab the window has not shown yet', () => { + const prior = mainSession() + const saved = mergeRendererPresentationSave( + { ...prior, unifiedTabs: { [WORKTREE]: [unified('editor', 'editor')] } }, + prior, + 'local' + ) + expect(saved.unifiedTabs?.[WORKTREE]?.map((tab) => tab.id)).toEqual(['editor', 'tab']) + }) + + it('a presentation patch stays one and cannot carry sleeping records', () => { + const prior = mainSession() + const patch = mergeRendererPresentationSave( + { + activeWorktreeId: WORKTREE, + sleepingAgentSessionsByPaneKey: {}, + defaultTerminalTabsAppliedByWorktreeId: undefined + }, + prior, + 'local' + ) + expect(patch).not.toHaveProperty('tabsByWorktree') + expect(patch).not.toHaveProperty('terminalLayoutsByTabId') + expect(patch.sleepingAgentSessionsByPaneKey).toBe(prior.sleepingAgentSessionsByPaneKey) + expect(patch.defaultTerminalTabsAppliedByWorktreeId).toEqual({ [WORKTREE]: true }) + }) + + it("keeps the window's terminal rows and sleeping records for another server's partition", () => { + const prior = { ...mainSession(), terminalTopologyRevisionByRepoId: { other: 1 } } + const window: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE]: [row('remote-tab', { ptyId: 'remote:env@@handle' })] }, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { [`remote-tab:${LEFT}`]: sleeping(`remote-tab:${LEFT}`, 2) } + } + + const saved = mergeRendererPresentationSave(window, prior, RUNTIME_HOST) + + expect(saved.tabsByWorktree).toEqual(window.tabsByWorktree) + expect(saved.sleepingAgentSessionsByPaneKey).toBe(window.sleepingAgentSessionsByPaneKey) + // Records only main writes survive a save into any partition. + expect(saved.closedTerminalTabTombstonesByTabId).toBe(prior.closedTerminalTabTombstonesByTabId) + expect(saved.terminalTopologyRevisionByRepoId).toBe(prior.terminalTopologyRevisionByRepoId) + // Once main fenced the repo there (a worktree removal), main's rows stand as before. + const fenced = mergeRendererPresentationSave(window, mainSession(), RUNTIME_HOST) + expect(fenced.tabsByWorktree).toEqual(mainSession().tabsByWorktree) + }) + + it('a save drops local residue of a workspace the catalog homes on SSH, as before the core', () => { + const prior = mainSession() + const window: WorkspaceSessionState = { ...getDefaultWorkspaceSession(), tabsByWorktree: {} } + + const saved = mergeRendererPresentationSave(window, prior, 'local', () => 'ssh:target') + expect(saved.tabsByWorktree).toEqual({}) + expect(saved.terminalLayoutsByTabId).toEqual({}) + // The workspace's own partition, or an unknown home, keeps main's rows. + for (const home of ['local', null] as const) { + const kept = mergeRendererPresentationSave(window, prior, 'local', () => home) + expect(kept.tabsByWorktree).toEqual(prior.tabsByWorktree) + } + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts new file mode 100644 index 000000000000..dc83760a8b87 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts @@ -0,0 +1,320 @@ +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { withTopologyRow } from '../../../shared/terminal-topology-tab-row' +import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { pruneTabGroupLayoutAfterRetirement } from '../../runtime/mobile-session-terminal-retirement' +import { projectTabRow } from '../../runtime/terminal-topology-projection' +import type { Store } from '../loading-store/store' +import { resolveHostId } from '../loading-store/session-host-partitions' +import { sameTerminalLeafSet } from './terminal-layout-set' +import { homeStrandedRowsBeforeWindowSave } from './terminal-local-residue-homing' +import { + hasHostAuthoritativeTerminalMembership, + isTerminalOwnerPartition +} from './terminal-topology-membership' + +type RendererSaveStore = Pick< + Store, + | 'getWorkspaceSession' + | 'setWorkspaceSession' + | 'patchWorkspaceSession' + | 'stageWorkspaceSessionBeforeUnload' +> & + Partial> + +// The window's session writes: `session:set`, `session:set-sync`, `session:patch` and the quit stage. +export function setRendererSession( + store: RendererSaveStore, + session: WorkspaceSessionState, + hostId?: string | null +): void { + store.setWorkspaceSession(overMainTopology(store, session, hostId), hostId) +} + +export function patchRendererSession( + store: RendererSaveStore, + patch: WorkspaceSessionPatch, + hostId?: string | null +): void { + const merged = overMainTopology(store, patch, hostId) + // A patch carries only the fields it names; main's fields it omits already stand. + store.patchWorkspaceSession( + Object.fromEntries(Object.entries(merged).filter(([key]) => Object.hasOwn(patch, key))), + hostId + ) +} + +export function stageRendererSessionBeforeUnload( + store: RendererSaveStore, + session: WorkspaceSessionState, + hostId?: string | null +): void { + store.stageWorkspaceSessionBeforeUnload(overMainTopology(store, session, hostId), hostId) +} + +function overMainTopology( + store: RendererSaveStore, + incoming: T, + hostId?: string | null +): T { + const homeHostIdOf = (worktreeId: string) => catalogHomeHostId(store, worktreeId) + homeStrandedRowsBeforeWindowSave(store, resolveHostId(hostId), homeHostIdOf) + return mergeRendererPresentationSave( + incoming, + store.getWorkspaceSession(hostId), + resolveHostId(hostId), + homeHostIdOf + ) +} + +/** The partition the repo catalog places a workspace on; null when the catalog can't say. */ +function catalogHomeHostId(store: RendererSaveStore, worktreeId: string): ExecutionHostId | null { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const workspace = store + .getFolderWorkspaces?.() + .find((entry) => entry.id === scope.folderWorkspaceId) + return workspace?.executionHostId + ? workspaceSessionPartitionHostId(workspace.executionHostId) + : null + } + const repo = store.getRepo?.( + getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId) + ) + return repo ? workspaceSessionPartitionHostId(getRepoExecutionHostId(repo)) : null +} + +/** + * A window save brings presentation: titles, colours, order, focus, buffers, tab groups. Main keeps + * what it authors: which tabs and panes exist, their creation fields, trees and PTY bindings, + * sleeping records, close records and fences. + */ +export function mergeRendererPresentationSave( + incoming: T, + prior: WorkspaceSessionState, + hostId: ExecutionHostId, + homeHostIdOf: (worktreeId: string) => ExecutionHostId | null = () => null +): T { + const ownerPartition = isTerminalOwnerPartition(hostId) + // Another server feeds a `runtime:` partition's rows; main's stand there only for a repo it fenced. + // Rows homed on another partition are residue main never writes; the window's save decides them (#26098). + const mainHolds = (worktreeId: string | undefined): boolean => + ownerPartition + ? worktreeId === undefined || (homeHostIdOf(worktreeId) ?? hostId) === hostId + : worktreeId !== undefined && hasHostAuthoritativeTerminalMembership(prior, worktreeId) + return { + ...incoming, + clientHostedBrowserPagesByWorktree: prior.clientHostedBrowserPagesByWorktree, + closedTerminalTabTombstonesByTabId: prior.closedTerminalTabTombstonesByTabId, + terminalPtyIncarnationsByPaneKey: prior.terminalPtyIncarnationsByPaneKey, + terminalSurfaceTombstonesByPaneKey: prior.terminalSurfaceTombstonesByPaneKey, + terminalTopologyRevisionByRepoId: prior.terminalTopologyRevisionByRepoId, + // Write-once: a payload that omits a mark has not un-applied it. + defaultTerminalTabsAppliedByWorktreeId: { + ...prior.defaultTerminalTabsAppliedByWorktreeId, + ...incoming.defaultTerminalTabsAppliedByWorktreeId + }, + ...(ownerPartition + ? { sleepingAgentSessionsByPaneKey: prior.sleepingAgentSessionsByPaneKey } + : {}), + ...(incoming.tabsByWorktree && { + tabsByWorktree: mainRows(incoming.tabsByWorktree, prior, mainHolds) + }), + ...(incoming.terminalLayoutsByTabId && { + terminalLayoutsByTabId: mainLayouts(incoming, prior, mainHolds) + }), + ...placementOfMainTabs(incoming, prior, mainHolds) + } +} + +type MainHolds = (worktreeId: string | undefined) => boolean + +function mainRows( + window: WorkspaceSessionState['tabsByWorktree'], + prior: WorkspaceSessionState, + mainHolds: MainHolds +): WorkspaceSessionState['tabsByWorktree'] { + const rows = Object.fromEntries(Object.entries(window).filter(([id]) => !mainHolds(id))) + for (const [worktreeId, tabs] of Object.entries(prior.tabsByWorktree)) { + if (mainHolds(worktreeId)) { + const windowRows = new Map((window[worktreeId] ?? []).map((tab) => [tab.id, tab])) + rows[worktreeId] = tabs.map((tab) => presentationRow(tab, windowRows.get(tab.id))) + } + } + return rows +} + +/** + * Main's row under the window's presentation. The window's `ptyId` is its live attachment, never + * main's binding. + */ +function presentationRow(main: TerminalTab, window: TerminalTab | undefined): TerminalTab { + return window ? withTopologyRow(window, projectTabRow(main)) : main +} + +function mainLayouts( + incoming: WorkspaceSessionPatch, + prior: WorkspaceSessionState, + mainHolds: MainHolds +): WorkspaceSessionState['terminalLayoutsByTabId'] { + const window = incoming.terminalLayoutsByTabId ?? {} + const worktreeOfTab = new Map() + for (const rows of [prior.tabsByWorktree, incoming.tabsByWorktree ?? {}]) { + for (const [worktreeId, tabs] of Object.entries(rows)) { + tabs.forEach((tab) => worktreeOfTab.set(tab.id, worktreeId)) + } + } + const layouts = Object.fromEntries( + Object.entries(window).filter(([tabId]) => !mainHolds(worktreeOfTab.get(tabId))) + ) + for (const [worktreeId, tabs] of Object.entries(prior.tabsByWorktree)) { + if (!mainHolds(worktreeId)) { + continue + } + for (const tab of tabs) { + const main = prior.terminalLayoutsByTabId?.[tab.id] + if (main) { + layouts[tab.id] = presentationLayout(main, window[tab.id]) + } + } + } + return layouts +} + +/** + * Per-leaf presentation applies only to the panes main holds; a different pane set is stale. A pane + * main binds that the window has not attached yet saved no scrollback, so main's stands. + */ +function presentationLayout( + main: TerminalLayoutSnapshot, + window: TerminalLayoutSnapshot | undefined +): TerminalLayoutSnapshot { + if (!window || !sameTerminalLeafSet(main.root, window.root)) { + return main + } + const unattached = new Set( + Object.keys(main.ptyIdsByLeafId ?? {}).filter((leafId) => !window.ptyIdsByLeafId?.[leafId]) + ) + return { + ...window, + root: main.root, + ptyIdsByLeafId: main.ptyIdsByLeafId, + ...withMainLeafRecords('buffersByLeafId', unattached, main, window), + ...withMainLeafRecords('scrollbackRefsByLeafId', unattached, main, window) + } +} + +function withMainLeafRecords( + field: 'buffersByLeafId' | 'scrollbackRefsByLeafId', + leafIds: ReadonlySet, + main: TerminalLayoutSnapshot, + window: TerminalLayoutSnapshot +): Partial { + const kept = Object.entries(main[field] ?? {}).filter(([leafId]) => leafIds.has(leafId)) + return kept.length > 0 ? { [field]: { ...Object.fromEntries(kept), ...window[field] } } : {} +} + +/** The window's tab bar, holding exactly main's terminal tabs. */ +function placementOfMainTabs( + incoming: WorkspaceSessionPatch, + prior: WorkspaceSessionState, + mainHolds: MainHolds +): WorkspaceSessionPatch { + const unifiedTabs = { ...incoming.unifiedTabs } + const tabGroups = { ...incoming.tabGroups } + const tabGroupLayouts = { ...incoming.tabGroupLayouts } + const activeTabIdByWorktree = { ...incoming.activeTabIdByWorktree } + const worktreeIds = new Set([ + ...Object.keys(prior.tabsByWorktree), + ...Object.keys(incoming.tabsByWorktree ?? {}) + ]) + for (const worktreeId of [...worktreeIds].filter(mainHolds)) { + const mainTabs = prior.tabsByWorktree[worktreeId] ?? [] + const memberIds = new Set(mainTabs.map((tab) => tab.id)) + const unified = incoming.unifiedTabs + ? memberUnifiedTabs( + incoming.unifiedTabs[worktreeId] ?? [], + prior.unifiedTabs?.[worktreeId] ?? [], + memberIds + ) + : (prior.unifiedTabs?.[worktreeId] ?? []) + if (unified.length > 0 || Object.hasOwn(unifiedTabs, worktreeId)) { + unifiedTabs[worktreeId] = unified + } + const validTabIds = new Set([...memberIds, ...unified.map((tab) => tab.id)]) + const groups = groupsOfTabs( + incoming.tabGroups?.[worktreeId] ?? prior.tabGroups?.[worktreeId] ?? [], + validTabIds + ) + if (groups.length > 0 || Object.hasOwn(tabGroups, worktreeId)) { + tabGroups[worktreeId] = groups + } + const groupLayout = pruneTabGroupLayoutAfterRetirement( + incoming.tabGroupLayouts?.[worktreeId] ?? prior.tabGroupLayouts?.[worktreeId], + new Set(groups.map((group) => group.id)) + ) + if (groupLayout) { + tabGroupLayouts[worktreeId] = groupLayout + } else { + delete tabGroupLayouts[worktreeId] + } + activeTabIdByWorktree[worktreeId] = + [ + activeTabIdByWorktree[worktreeId], + prior.activeTabIdByWorktree?.[worktreeId], + groups[0]?.activeTabId, + mainTabs[0]?.id + ].find((tabId) => tabId != null && validTabIds.has(tabId)) ?? null + } + return { + ...(incoming.unifiedTabs && { unifiedTabs }), + ...(incoming.tabGroups && { tabGroups }), + ...(incoming.tabGroupLayouts && { tabGroupLayouts }), + ...(incoming.activeTabIdByWorktree && { activeTabIdByWorktree }) + } +} + +/** Terminal entries are a join on main's tabs (by `entityId` or id); other entries are the window's. */ +function memberUnifiedTabs( + window: readonly Tab[], + main: readonly Tab[], + memberIds: ReadonlySet +): Tab[] { + const isMember = (tab: Tab): boolean => + tab.contentType === 'terminal' && (memberIds.has(tab.id) || memberIds.has(tab.entityId)) + const kept = window.filter((tab) => tab.contentType !== 'terminal' || isMember(tab)) + const shown = new Set(kept.flatMap((tab) => (isMember(tab) ? [tab.id, tab.entityId] : []))) + return [ + ...kept, + ...main.filter((tab) => isMember(tab) && !shown.has(tab.id) && !shown.has(tab.entityId)) + ] +} + +function groupsOfTabs(groups: readonly TabGroup[], validTabIds: ReadonlySet): TabGroup[] { + return groups.flatMap((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) + if (tabOrder.length === 0) { + return [] + } + const activeTabId = + group.activeTabId && tabOrder.includes(group.activeTabId) ? group.activeTabId : tabOrder[0] + return [ + { + ...group, + tabOrder, + activeTabId, + // Assigned even when empty: `...group` would otherwise bring back the unfiltered ids. + ...(group.recentTabIds && { + recentTabIds: group.recentTabIds.filter((tabId) => validTabIds.has(tabId)) + }) + } + ] + }) +} diff --git a/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts index 297a99a3eb0b..2e642cca6a5d 100644 --- a/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts +++ b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts @@ -15,14 +15,33 @@ import { reopenTopologyStore } from './terminal-topology-profile-fixture' +const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({ + syncHandlers: new Map void>(), + invokeHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + on: ( + channel: string, + handler: (event: { returnValue?: unknown }, ...args: unknown[]) => void + ) => syncHandlers.set(channel, handler), + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => + invokeHandlers.set(channel, handler) + } +})) vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../ssh/ssh-config-parser', () => ({ loadUserSshConfig: () => ({ hosts: [] }), sshConfigHostsToTargets: () => [] })) -// The mirror refactor moves sleeping records into main's own commits and makes window saves -// presentation-only; a slept agent must still be resumable after quit and relaunch. +import { registerRendererShutdownCheckpointHandler } from '../../ipc/renderer-shutdown-checkpoint' +import { registerSessionHandlers } from '../../ipc/session' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' + +// Sleeping records are main's own commits and window saves are presentation-only; a slept agent +// must still be resumable after quit and relaunch. const TAB = 'tab-agent' const LEAF = '11111111-1111-4111-8111-111111111111' @@ -32,6 +51,8 @@ const PANE_KEY = `${TAB}:${LEAF}` const directories: string[] = [] afterEach(() => { + syncHandlers.clear() + invokeHandlers.clear() for (const directory of directories.splice(0)) { rmSync(directory, { recursive: true, force: true }) } @@ -83,16 +104,40 @@ function windowSessionWithAgentPane(store: Store): WorkspaceSessionState { return session } +/** The window's IPC into main: its own sleeping-record commits, its saves and its quit stage. */ +function windowChannels(store: Store) { + registerSessionHandlers(store, new OrcaRuntimeService(store)) + registerRendererShutdownCheckpointHandler(store) + return { + commitSleep: (held: SleepingAgentSessionRecord) => + invokeHandlers.get('session:commit-terminal-sleeping-records')!( + {}, + { sleep: { [PANE_KEY]: held }, wake: [] } + ), + save: (session: WorkspaceSessionState) => + invokeHandlers.get('session:set')!({}, structuredClone(session)), + stageQuit: (session: WorkspaceSessionState) => { + const event: { returnValue?: unknown } = {} + syncHandlers.get('app:stage-before-unload-sync')!(event, { + sessions: [{ state: structuredClone(session) }], + ui: {} + }) + return event.returnValue + } + } +} + async function storeWithAgentPane() { const directory = mkdtempSync(join(tmpdir(), 'orca-sleep-quit-resume-')) directories.push(directory) const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) const window = windowSessionWithAgentPane(store) + // Main's own rows, as the spawn's placement wrote them. store.setWorkspaceSession(structuredClone(window)) await expect( store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: LEAF, ptyId: PTY }) ).resolves.toBe(true) - return { directory, store, window } + return { directory, store, window, channels: windowChannels(store) } } /** What main holds for the slept pane, and what the window would show from its slice. */ @@ -120,13 +165,14 @@ function resumeCommand(held: SleepingAgentSessionRecord | undefined): string | u describe('sleep → quit → resume', () => { it('keeps the quit capture over the periodic one through quit and relaunch', async () => { - const { directory, store, window } = await storeWithAgentPane() - // The agent is slept; the periodic save carries the first capture. + const { directory, store, window, channels } = await storeWithAgentPane() + // The agent is slept; the periodic capture commits it and the window's save carries it too. + channels.commitSleep(record('worktree-sleep', 1)) window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('worktree-sleep', 1) } - store.setWorkspaceSession(structuredClone(window)) - // Quit: the window's quit capture rides only on the synchronous before-unload stage. - window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 2) } - store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + channels.save(window) + // Quit: the quit capture commits, then the stage carries the window's older copy. + channels.commitSleep(record('quit', 2)) + expect(channels.stageQuit(window)).toEqual({ ok: true }) const relaunched = await reopenTopologyStore(store, directory) try { @@ -138,7 +184,7 @@ describe('sleep → quit → resume', () => { } expect(heldForPane(relaunched)).toEqual(expected) // The relaunched window's first save is its hydrated copy; the record stays. - relaunched.setWorkspaceSession(structuredClone(relaunched.getWorkspaceSession())) + windowChannels(relaunched).save(relaunched.getWorkspaceSession()) expect(heldForPane(relaunched)).toEqual(expected) expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-2'") } finally { @@ -146,10 +192,10 @@ describe('sleep → quit → resume', () => { } }) - it('keeps a record that only the before-unload stage carried', async () => { - const { directory, store, window } = await storeWithAgentPane() - window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 3) } - store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + it('keeps a committed record through a quit stage whose window copy has none', async () => { + const { directory, store, window, channels } = await storeWithAgentPane() + channels.commitSleep(record('quit', 3)) + channels.stageQuit({ ...window, sleepingAgentSessionsByPaneKey: {} }) const relaunched = await reopenTopologyStore(store, directory) try { diff --git a/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts b/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts new file mode 100644 index 000000000000..2892a79028ec --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts @@ -0,0 +1,209 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { Store } from '../loading-store/store' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID as WORKTREE, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' +import { sleepLeaf, wakeLeaf } from './terminal-topology-commit' + +const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({ + syncHandlers: new Map void>(), + invokeHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + on: (channel: string, handler: (event: { returnValue?: unknown }, args: unknown) => void) => + syncHandlers.set(channel, handler), + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => + invokeHandlers.set(channel, handler) + } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +import { registerRendererShutdownCheckpointHandler } from '../../ipc/renderer-shutdown-checkpoint' +import { registerSessionHandlers } from '../../ipc/session' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { RuntimeNotifier } from '../../runtime/runtime-notifier-contract' + +const TAB = 'tab-agent' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `${TAB}:${LEAF}` +const OTHER_PANE_KEY = `tab-elsewhere:${LEAF}` +const COMMIT = 'session:commit-terminal-sleeping-records' + +const directories: string[] = [] +const stores: Store[] = [] + +afterEach(async () => { + for (const store of stores.splice(0)) { + await store.freezeWritesAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + syncHandlers.clear() + invokeHandlers.clear() +}) + +function record(paneKey: string, capturedAt: number): SleepingAgentSessionRecord { + return { + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${capturedAt}` }, + prompt: 'finish the task', + state: 'waiting', + capturedAt, + updatedAt: capturedAt, + origin: 'worktree-sleep' + } +} + +function sessionWithAgentTab(session: WorkspaceSessionState): WorkspaceSessionState { + return { + ...session, + tabsByWorktree: { + [WORKTREE]: [ + { + id: TAB, + ptyId: null, + worktreeId: WORKTREE, + title: 'codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB]: { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF, expandedLeafId: null } + } + } +} + +function notifier(pushes: TerminalTopologySlice[]): RuntimeNotifier { + const ignore = (): void => {} + return { + worktreesChanged: ignore, + reposChanged: ignore, + activateWorktree: ignore, + createTerminal: ignore, + splitTerminal: ignore, + renameTerminal: ignore, + focusTerminal: ignore, + closeTerminal: ignore, + sleepWorktree: ignore, + terminalFitOverrideChanged: ignore, + terminalDriverChanged: ignore, + terminalTopologyChanged: (slice) => pushes.push(slice) + } +} + +/** A real store holding the agent's tab, main's IPC handlers, and the pushes main publishes. */ +async function setup() { + const directory = mkdtempSync(join(tmpdir(), 'orca-sleep-wake-commit-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + stores.push(store) + store.setWorkspaceSession(sessionWithAgentTab(store.getWorkspaceSession())) + const runtime = new OrcaRuntimeService(store) + const pushes: TerminalTopologySlice[] = [] + runtime.setNotifier(notifier(pushes)) + runtime.getTerminalTopologySlices() + registerSessionHandlers(store, runtime) + registerRendererShutdownCheckpointHandler(store) + const invoke = (channel: string, args: unknown): unknown => invokeHandlers.get(channel)!({}, args) + const nextPush = async (): Promise => { + pushes.length = 0 + await Promise.resolve() + return pushes.find((slice) => slice.worktreeId === WORKTREE) + } + return { directory, store, invoke, nextPush } +} + +describe('sleepLeaf and wakeLeaf', () => { + const session = sessionWithAgentTab(getDefaultWorkspaceSession()) + const home = (): boolean => true + + it('a partition takes only the records whose tab it holds', () => { + const next = sleepLeaf( + session, + { [PANE_KEY]: record(PANE_KEY, 1), [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) }, + home + ) + expect(next.sleepingAgentSessionsByPaneKey).toEqual({ [PANE_KEY]: record(PANE_KEY, 1) }) + expect(sleepLeaf(session, { [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) }, home)).toBe(session) + }) + + it('an unchanged record or an absent wake leaves the session as it was', () => { + const held = sleepLeaf(session, { [PANE_KEY]: record(PANE_KEY, 1) }, home) + expect(sleepLeaf(held, { [PANE_KEY]: record(PANE_KEY, 1) }, home)).toBe(held) + expect(wakeLeaf(held, [OTHER_PANE_KEY], home)).toBe(held) + expect(wakeLeaf(held, [PANE_KEY], home).sleepingAgentSessionsByPaneKey).toEqual({}) + }) + + it("a partition that is not the worktree's home is left alone, even holding the tab", () => { + const held = sleepLeaf(session, { [PANE_KEY]: record(PANE_KEY, 1) }, home) + const notHome = (): boolean => false + expect(sleepLeaf(session, { [PANE_KEY]: record(PANE_KEY, 2) }, notHome)).toBe(session) + expect(wakeLeaf(held, [PANE_KEY], notHome)).toBe(held) + }) +}) + +describe('sleep and wake commits', () => { + it('a sleep commit arrives in the next slice, and a wake removes it', async () => { + const { invoke, nextPush } = await setup() + + invoke(COMMIT, { sleep: { [PANE_KEY]: record(PANE_KEY, 1) }, wake: [] }) + expect((await nextPush())?.sleeping).toEqual({ [PANE_KEY]: record(PANE_KEY, 1) }) + + invoke(COMMIT, { sleep: {}, wake: [PANE_KEY] }) + expect((await nextPush())?.sleeping).toEqual({}) + }) + + it('drops a malformed record and a record whose tab main does not hold', async () => { + const { store, invoke } = await setup() + + invoke(COMMIT, { + sleep: { + [PANE_KEY]: { ...record(PANE_KEY, 1), agent: 'not-an-agent' }, + [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) + } + }) + + expect(store.getWorkspaceSession().sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + }) + + it('a committed quit capture survives a quit stage that carries no window session', async () => { + const { directory, store, invoke } = await setup() + const quitRecord = { ...record(PANE_KEY, 2), origin: 'quit' as const } + const event: { returnValue?: unknown } = {} + + invoke(COMMIT, { sleep: { [PANE_KEY]: quitRecord }, wake: [] }) + syncHandlers.get('app:stage-before-unload-sync')!(event, { sessions: [], ui: {} }) + + expect(event.returnValue).toEqual({ ok: true }) + stores.splice(stores.indexOf(store), 1) + const relaunched = await reopenTopologyStore(store, directory) + stores.push(relaunched) + expect(relaunched.getWorkspaceSession().sleepingAgentSessionsByPaneKey).toEqual({ + [PANE_KEY]: quitRecord + }) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts new file mode 100644 index 000000000000..6e90ab08dc1a --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts @@ -0,0 +1,396 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { TerminalSurfaceCreateRequest } from '../../../shared/terminal-surface-create' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID as WORKTREE, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({ + syncHandlers: new Map void>(), + invokeHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + on: ( + channel: string, + handler: (event: { returnValue?: unknown }, ...args: unknown[]) => void + ) => syncHandlers.set(channel, handler), + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => + invokeHandlers.set(channel, handler) + } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +import { registerRendererShutdownCheckpointHandler } from '../../ipc/renderer-shutdown-checkpoint' +import { registerSessionHandlers } from '../../ipc/session' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' + +// A tab or pane the window creates is main's from creation, so it outlives a spawn that fails, +// never starts, or waits on an SSH connection, through quit and relaunch. + +const SSH_WORKTREE = 'repo-remote::/fixture/remote' +const SSH_HOST: ExecutionHostId = 'ssh:build-host' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' + +const directories: string[] = [] + +afterEach(() => { + syncHandlers.clear() + invokeHandlers.clear() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function row(id: string, worktreeId: string, customTitle: string | null = null): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + customTitle, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +async function windowOnStore() { + const directory = mkdtempSync(join(tmpdir(), 'orca-surface-create-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + registerSessionHandlers(store, new OrcaRuntimeService(store)) + registerRendererShutdownCheckpointHandler(store) + return { + directory, + store, + create: (request: TerminalSurfaceCreateRequest) => + invokeHandlers.get('session:terminal-create-surface')!({}, request), + /** The window's quit: its session as it shows it, staged over main's topology. */ + stageQuit: (state: WorkspaceSessionState, hostId?: ExecutionHostId) => { + const event: { returnValue?: unknown } = {} + syncHandlers.get('app:stage-before-unload-sync')!(event, { + sessions: [{ state: structuredClone(state), ...(hostId ? { hostId } : {}) }], + ui: {} + }) + return event.returnValue + } + } +} + +function newTab(worktreeId: string, tab: TerminalTab, leafId?: string) { + return { + worktreeId, + tabId: tab.id, + ...(leafId ? { leafId } : {}), + placement: { kind: 'new-tab' as const, row: { title: tab.title, createdAt: tab.createdAt } } + } +} + +describe('a tab or pane the window creates, quit before it binds', () => { + it('keeps a background worktree’s default tabs, never mounted, and their applied mark', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tabs = [row('tab-default-1', WORKTREE, 'renamed'), row('tab-default-2', WORKTREE)] + for (const tab of tabs) { + await expect(create(newTab(WORKTREE, tab))).resolves.toMatchObject({ status: 'committed' }) + } + const window = structuredClone(store.getWorkspaceSession()) + window.tabsByWorktree = { [WORKTREE]: tabs } + window.defaultTerminalTabsAppliedByWorktreeId = { [WORKTREE]: true } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect(session.tabsByWorktree[WORKTREE]?.map((tab) => [tab.id, tab.customTitle])).toEqual([ + ['tab-default-1', 'renamed'], + ['tab-default-2', null] + ]) + expect(session.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe(true) + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps a split whose spawn failed, with its title, and a later spawn only binds it', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab = row('tab-split', WORKTREE) + // The tab first, then its first pane once it mounts. + await create(newTab(WORKTREE, tab)) + await create(newTab(WORKTREE, tab, LEFT)) + const split = { + worktreeId: WORKTREE, + tabId: tab.id, + leafId: RIGHT, + placement: { kind: 'split' as const, parentLeafId: LEFT, direction: 'horizontal' as const } + } + await expect(create(split)).resolves.toMatchObject({ status: 'committed' }) + // Idempotent: the same creation again changes nothing. + const before = structuredClone(store.getWorkspaceSession()) + await expect(create(split)).resolves.toMatchObject({ status: 'committed' }) + expect(store.getWorkspaceSession()).toEqual(before) + + const window = structuredClone(store.getWorkspaceSession()) + window.terminalLayoutsByTabId[tab.id] = { + ...window.terminalLayoutsByTabId[tab.id], + titlesByLeafId: { [RIGHT]: 'build watch' } + } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const expectedRoot = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + } + const layout = relaunched.getWorkspaceSession().terminalLayoutsByTabId[tab.id] + expect(layout?.root).toEqual(expectedRoot) + expect(layout?.titlesByLeafId).toEqual({ [RIGHT]: 'build watch' }) + + // The spawn's placement for a pane main already holds only binds it. + await expect( + relaunched.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: tab.id, + leafId: RIGHT, + ptyId: 'pty-right', + placement: split.placement + }) + ).resolves.toBe(true) + const bound = relaunched.getWorkspaceSession().terminalLayoutsByTabId[tab.id] + expect(bound?.root).toEqual(expectedRoot) + expect(bound?.ptyIdsByLeafId).toEqual({ [RIGHT]: 'pty-right' }) + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps an SSH pane waiting for its connection, in the SSH host’s partition', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab = row('tab-ssh', SSH_WORKTREE) + await expect(create(newTab(SSH_WORKTREE, tab, LEFT))).resolves.toMatchObject({ + status: 'committed' + }) + expect(stageQuit(store.getWorkspaceSession(SSH_HOST), SSH_HOST)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession(SSH_HOST) + expect(session.tabsByWorktree[SSH_WORKTREE]?.map((entry) => entry.id)).toEqual([tab.id]) + expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual({ type: 'leaf', leafId: LEFT }) + expect(relaunched.getWorkspaceSession().tabsByWorktree[SSH_WORKTREE]).toBeUndefined() + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps an agent launch tab, its agent and its laid-out pane, before the agent exists', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab: TerminalTab = { ...row('tab-launch', WORKTREE), launchAgent: 'claude' } + // The request a published launch tab sends: its host ids and the launch in the row. + await expect( + create({ + worktreeId: WORKTREE, + tabId: tab.id, + leafId: LEFT, + placement: { + kind: 'new-tab', + row: { title: tab.title, createdAt: tab.createdAt, launchAgent: 'claude' } + } + }) + ).resolves.toMatchObject({ status: 'committed' }) + const window = structuredClone(store.getWorkspaceSession()) + window.tabsByWorktree = { [WORKTREE]: [{ ...tab, agentLaunchPane: { leafId: LEFT } }] } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect(session.tabsByWorktree[WORKTREE]).toEqual([ + expect.objectContaining({ + id: tab.id, + ptyId: null, + launchAgent: 'claude', + agentLaunchPane: { leafId: LEFT } + }) + ]) + expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual({ type: 'leaf', leafId: LEFT }) + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps a tab adopted for a live PTY whose pane has not mounted, bound to it', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab: TerminalTab = { ...row('tab-adopted', WORKTREE), ptyId: 'pty-live' } + // The adoption sends its create and its binding back to back, without awaiting the create. + const created = create(newTab(WORKTREE, tab, LEFT)) + const bound = invokeHandlers.get('session:terminal-bind-leaf')!( + {}, + { worktreeId: WORKTREE, tabId: tab.id, leafId: LEFT, ptyId: 'pty-live' } + ) + await expect(created).resolves.toMatchObject({ status: 'committed' }) + await expect(bound).resolves.toMatchObject({ status: 'bound' }) + const window = structuredClone(store.getWorkspaceSession()) + window.tabsByWorktree = { [WORKTREE]: [tab] } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect(session.tabsByWorktree[WORKTREE]?.map((entry) => [entry.id, entry.ptyId])).toEqual([ + [tab.id, 'pty-live'] + ]) + expect(session.terminalLayoutsByTabId[tab.id]).toMatchObject({ + root: { type: 'leaf', leafId: LEFT }, + ptyIdsByLeafId: { [LEFT]: 'pty-live' } + }) + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps a floating terminal tab, which no repo catalogs, in the local partition', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab = row('tab-floating', FLOATING_TERMINAL_WORKTREE_ID, 'scratch') + await expect(create(newTab(FLOATING_TERMINAL_WORKTREE_ID, tab, LEFT))).resolves.toMatchObject({ + status: 'committed' + }) + const window = structuredClone(store.getWorkspaceSession()) + window.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [tab] } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect( + session.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.map((entry) => [ + entry.id, + entry.customTitle + ]) + ).toEqual([[tab.id, 'scratch']]) + expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual({ type: 'leaf', leafId: LEFT }) + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('refuses a tab main recorded as closed, and a split of a pane it does not hold', async () => { + const { store, create } = await windowOnStore() + const tab = row('tab-closed', WORKTREE) + await create(newTab(WORKTREE, tab, LEFT)) + await invokeHandlers.get('session:close-terminal-surface')!( + {}, + { worktreeId: WORKTREE, target: { kind: 'tab', tabId: tab.id } } + ) + await expect(create(newTab(WORKTREE, tab, LEFT))).resolves.toMatchObject({ + status: 'refused', + reason: 'tab_not_held' + }) + const open = row('tab-open', WORKTREE) + await create(newTab(WORKTREE, open, LEFT)) + await expect( + create({ + worktreeId: WORKTREE, + tabId: open.id, + leafId: RIGHT, + placement: { kind: 'split', parentLeafId: RIGHT, direction: 'vertical' } + }) + ).resolves.toMatchObject({ status: 'refused', reason: 'parent_missing' }) + expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE]?.map((entry) => entry.id)).toEqual([ + open.id + ]) + }) +}) + +describe('a tab a phone closes on the desktop', () => { + // The window commits the close, then saves its whole session before answering the phone. + it('stays closed through the window’s full save and a relaunch, keeping the rest', async () => { + const { directory, store, create } = await windowOnStore() + const kept = row('tab-kept', WORKTREE) + const closed = row('tab-closed', WORKTREE) + await create(newTab(WORKTREE, kept, LEFT)) + await create(newTab(WORKTREE, closed, RIGHT)) + const before = structuredClone(store.getWorkspaceSession()) + + const close = invokeHandlers.get('session:close-terminal-surface')!( + {}, + { worktreeId: WORKTREE, target: { kind: 'tab', tabId: closed.id } } + ) + // A debounced save taken before the close races it; the full save follows. + await invokeHandlers.get('session:set')!({}, before) + const window = structuredClone(before) + window.tabsByWorktree = { [WORKTREE]: [{ ...kept, customTitle: 'server' }] } + await invokeHandlers.get('session:set')!({}, window) + await close + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect(session.tabsByWorktree[WORKTREE]?.map((tab) => [tab.id, tab.customTitle])).toEqual([ + [kept.id, 'server'] + ]) + expect(session.terminalLayoutsByTabId[closed.id]).toBeUndefined() + } finally { + await relaunched.freezeWritesAsync() + } + }) +}) + +describe('an SSH workspace whose tabs an older build left in the local partition', () => { + it('keeps them through the first save and a relaunch, in the SSH partition', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stranded-ssh-rows-')) + directories.push(directory) + const tab = row('tab-stranded', SSH_WORKTREE, 'server') + const layout = { + root: { type: 'leaf' as const, leafId: LEFT }, + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: 'pty-stranded' } + } + const profile = JSON.parse(emptyTerminalSessionProfile()) + profile.workspaceSession.tabsByWorktree = { [SSH_WORKTREE]: [tab] } + profile.workspaceSession.terminalLayoutsByTabId = { [tab.id]: layout } + const store = await openTopologyStore(directory, JSON.stringify(profile)) + registerSessionHandlers(store, new OrcaRuntimeService(store)) + + // The window shows the reunited rows and routes them to the workspace's own partition. + const local = structuredClone(store.getWorkspaceSession()) + local.tabsByWorktree = {} + local.terminalLayoutsByTabId = {} + await invokeHandlers.get('session:set')!({}, local) + const ssh = structuredClone(store.getWorkspaceSession(SSH_HOST)) + ssh.tabsByWorktree = { [SSH_WORKTREE]: [tab] } + ssh.terminalLayoutsByTabId = { [tab.id]: layout } + await invokeHandlers.get('session:set')!({}, ssh, SSH_HOST) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession(SSH_HOST) + expect(session.tabsByWorktree[SSH_WORKTREE]?.map((entry) => entry.customTitle)).toEqual([ + 'server' + ]) + expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual(layout.root) + } finally { + await relaunched.freezeWritesAsync() + } + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-surface-create.ts b/src/main/persistence/terminal-topology/terminal-surface-create.ts new file mode 100644 index 000000000000..35b1f38d80cf --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-surface-create.ts @@ -0,0 +1,56 @@ +import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' +import type { + TerminalSurfaceCreateRequest, + TerminalSurfaceCreateResult +} from '../../../shared/terminal-surface-create' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' +import { placeTerminalPane } from './terminal-pane-placement-apply' +import { + advanceTerminalTopologyRevision, + startsOrAdvancesTerminalFence +} from './terminal-topology-membership' + +/** + * Records a tab or pane the window created in the worktree's home partition, unbound; its spawn + * later only binds. A new tab needs a `new-tab` placement and an id never closed; a pane joins an + * empty tree, or a split of a pane main holds. `session` is null when nothing changes. + */ +export function planTerminalSurfaceCreate( + home: WorkspaceSessionState, + request: TerminalSurfaceCreateRequest +): { result: TerminalSurfaceCreateResult; session: WorkspaceSessionState | null } { + const { worktreeId, tabId, leafId, placement } = request + const refuse = (reason: 'tab_not_held' | 'parent_missing') => ({ + result: { status: 'refused', reason } as const, + session: null + }) + const tabHeld = home.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) === true + if ( + !tabHeld && + (placement.kind !== 'new-tab' || + hasClosedTerminalTabRecord(home.closedTerminalTabTombstonesByTabId, tabId)) + ) { + return refuse('tab_not_held') + } + const root = home.terminalLayoutsByTabId?.[tabId]?.root ?? null + if (tabHeld && (leafId === undefined || layoutContainsLeafId(root, leafId))) { + return { result: { status: 'committed' }, session: null } + } + if ( + leafId !== undefined && + root && + !(placement.kind === 'split' && layoutContainsLeafId(root, placement.parentLeafId)) + ) { + return refuse('parent_missing') + } + const session = cloneWorkspaceSessionState(home) + placeTerminalPane(session, { worktreeId, tabId, leafId }, placement) + return { + result: { status: 'committed' }, + session: startsOrAdvancesTerminalFence(home, worktreeId, false) + ? advanceTerminalTopologyRevision(session, worktreeId) + : session + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts index 575a6a319066..1088e01afec4 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts @@ -26,7 +26,6 @@ const ALLOWED_REFERENCES: Record = { // Several runtime files only check it exists, then write through setWorkspaceSessionForWorktree. setWorkspaceSession: [ 'ipc/pty/pane/stable-owner.ts', - 'ipc/session.ts', // Store-internal: patchWorkspaceSession -> setWorkspaceSession. 'persistence/loading-store/session-snapshot-operations.ts', // Test support: seeds sessions for the acknowledged-tab retirement audit. @@ -50,8 +49,8 @@ const ALLOWED_REFERENCES: Record = { setHostWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'], // The runtime's session controller, reachable from every OrcaRuntime mixin. setForWorktree: ['runtime/orca-runtime-get-runtime-id.ts'], - patchWorkspaceSession: ['ipc/session.ts'], - stageWorkspaceSessionBeforeUnload: ['ipc/renderer-shutdown-checkpoint.ts'], + patchWorkspaceSession: [], + stageWorkspaceSessionBeforeUnload: [], setWorkspaceSessionForWorktree: [ // Headless editor-tab retirement, like the headless mobile-session tab writers below. 'runtime/mobile-session-editor-projection.ts', diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts index 8b086c56e394..df2e6c8cf91b 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts @@ -4,7 +4,7 @@ import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surfac import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' import type { TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' -import { closeLeafOrTab } from './terminal-topology-commit' +import { closeLeafOrTab, importPeerTopology } from './terminal-topology-commit' // An ssh: partition, so a leaked host id or worktree path would show in the span. const HOST_ID: ExecutionHostId = 'ssh:target-1' @@ -149,4 +149,122 @@ describe('persistence.terminal-topology span', () => { expect(records[0].attributes).toMatchObject({ 'topology.outcome': 'threw' }) expect(records[0].exit).toMatchObject({ _tag: 'Failure' }) }) + + it("commits an SSH import into that target's partition alone", () => { + const patchWorkspaceSession = vi.fn() + const unfenced = { ...session(), terminalTopologyRevisionByRepoId: {} } + const store = { getWorkspaceSession: () => unfenced, patchWorkspaceSession } + importPeerTopology(store, 'target-1', { tabsByWorktree: {} }, () => false) + importPeerTopology(store, 'target-1', {}, () => false) + + expect(patchWorkspaceSession.mock.calls).toEqual([[{ tabsByWorktree: {} }, HOST_ID]]) + expect(records.map((record) => record.attributes)).toEqual([ + { + kind: 'persistence', + 'topology.kind': 'import_peer_topology', + 'topology.outcome': 'committed' + }, + { kind: 'persistence', 'topology.kind': 'import_peer_topology', 'topology.outcome': 'noop' } + ]) + }) +}) + +describe('an SSH import against a mirror main has published past', () => { + const NEW_TAB = 'tab-created-by-cli' + const HOST_TAB = 'tab-from-peer' + + // What main committed after the window read its mirror: a CLI-created tab, and the split's + // second pane bound to its PTY. + function mainAfterWindowRead(): WorkspaceSessionState { + const prior = session() + return { + ...prior, + tabsByWorktree: { + [WORKTREE_ID]: [...prior.tabsByWorktree[WORKTREE_ID], tab(NEW_TAB, 'pty-new')] + }, + terminalLayoutsByTabId: { + ...prior.terminalLayoutsByTabId, + [SPLIT_TAB]: { + ...prior.terminalLayoutsByTabId[SPLIT_TAB], + ptyIdsByLeafId: { [LEAF_1]: 'pty-1', [LEAF_2]: 'pty-2-bound-late' } + } + } + } + } + + // The window's merge of the host snapshot over its older mirror: no CLI tab, the stale binding, + // and a tab a peer created on the host. + function windowPull() { + const read = session() + return { + tabsByWorktree: { + [WORKTREE_ID]: [...read.tabsByWorktree[WORKTREE_ID], tab(HOST_TAB, 'pty-peer')] + }, + terminalLayoutsByTabId: read.terminalLayoutsByTabId + } + } + + function importInto(main: WorkspaceSessionState, publishedSince: boolean) { + let written: Partial = {} + const kept = importPeerTopology( + { + getWorkspaceSession: () => main, + patchWorkspaceSession: (patch) => { + written = patch + } + }, + 'target-1', + windowPull(), + () => publishedSince + ) + return { kept, written } + } + + it("keeps main's newer rows and adds the host's new tab", () => { + const { kept, written } = importInto(mainAfterWindowRead(), true) + + expect(written.tabsByWorktree?.[WORKTREE_ID]?.map((row) => row.id)).toEqual([ + SPLIT_TAB, + PINNED_TAB, + NEW_TAB, + HOST_TAB + ]) + expect(written.terminalLayoutsByTabId?.[SPLIT_TAB]?.ptyIdsByLeafId?.[LEAF_2]).toBe( + 'pty-2-bound-late' + ) + expect(kept).toBe(true) + }) + + it('does not revive a tab main closed after the window read it', () => { + const main = mainAfterWindowRead() + const { written } = importInto( + { + ...main, + closedTerminalTabTombstonesByTabId: { + [HOST_TAB]: { closedAt: Date.now(), worktreeId: WORKTREE_ID, reason: 'user' } + } + }, + true + ) + + expect(written.tabsByWorktree?.[WORKTREE_ID]?.map((row) => row.id)).not.toContain(HOST_TAB) + }) + + it('applies the pull as the window merged it when main has published nothing since', () => { + const unfenced = { ...mainAfterWindowRead(), terminalTopologyRevisionByRepoId: {} } + const { kept, written } = importInto(unfenced, false) + + expect(written).toEqual(windowPull()) + expect(kept).toBe(false) + }) + + it("keeps main's rows and adds no host tab in a repo main fenced, as its rebase did", () => { + const { written } = importInto(mainAfterWindowRead(), false) + + expect(written.tabsByWorktree?.[WORKTREE_ID]?.map((row) => row.id)).toEqual([ + SPLIT_TAB, + PINNED_TAB, + NEW_TAB + ]) + }) }) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index dc1b562a51da..2298de7bcb6f 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -1,24 +1,50 @@ -import type { ExecutionHostId } from '../../../shared/execution-host' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { PersistedState } from '../../../shared/persisted-state-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { structuralValuesEqual } from '../../../shared/structural-value-equality' import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../../shared/terminal-leaf-move' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { + TerminalLayoutSetRequest, + TerminalLayoutSetResult +} from '../../../shared/terminal-layout-set' +import type { TerminalLeafBindRequest } from '../../../shared/terminal-leaf-bind' +import type { TerminalSurfaceCreateRequest } from '../../../shared/terminal-surface-create' +import type { TerminalSleepingRecordChanges } from '../../../shared/terminal-topology-slice' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import type { PtyBindingPersistenceOperations } from '../loading-store/pty-binding-persistence' import { startSpan } from '../../observability/tracer' import { terminalSurfaceCloseMutation, type TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' +import type { Store } from '../loading-store/store' import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' +import { planTerminalLayoutSet } from './terminal-layout-set' +import { planTerminalSurfaceCreate } from './terminal-surface-create' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' +import { overNewerMainRows } from './terminal-peer-import-rows' -// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and -// the pane move; the close transform still lives in runtime/ and other writers move here later. +// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings, sleeping agents): +// every window-requested topology change, creation included, commits and is traced here. A spawn's +// binding commits through `persistPtyBinding`, which `bindLeaf` also uses. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ -type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' +type TerminalTopologyCommitKind = + | 'create_tab' + | 'create_leaf' + | 'close_leaf' + | 'close_tab' + | 'move_leaf' + | 'set_layout' + | 'import_peer_topology' export function closeLeafOrTab( commit: TerminalSurfaceCloseCommit @@ -31,18 +57,167 @@ export function closeLeafOrTab( ) } -/** Moves a leaf, its binding and its pane-keyed records into a new tab in one durable mutation. */ +/** Records a tab or pane the window created, unbound, in `hostId`, the worktree's home. */ +export function createLeafOrTab( + request: TerminalSurfaceCreateRequest, + hostId: ExecutionHostId, + context: TerminalTopologyCommitContext +) { + return traced( + request.placement.kind === 'new-tab' ? 'create_tab' : 'create_leaf', + commitPlan((home) => planTerminalSurfaceCreate(home, request), hostId, context), + (result) => (result.status === 'refused' ? result.reason : undefined) + ) +} + +/** Moves a leaf, its binding and its pane-keyed records into a new tab in `hostId`, the worktree's home. */ export function moveLeaf( request: TerminalLeafMoveRequest, + hostId: ExecutionHostId, context: TerminalTopologyCommitContext ): () => DurableProfileStateMutation { return traced( 'move_leaf', - () => commitLeafMove(request, context), + () => commitLeafMove(request, hostId, context), (result) => (result.status === 'refused' ? result.reason : undefined) ) } +/** Records a live PTY the window adopted onto a pane main holds, as a reattach: never creates the pane. */ +export function bindLeaf( + bindings: Pick, + request: TerminalLeafBindRequest, + hostId: ExecutionHostId +): Promise { + return bindings.persistPtyBinding( + { ...request, mayCreate: false, mayReviveRetiredSurface: false, origin: 'reattach' }, + hostId + ) +} + +/** Replaces a tab's tree in `hostId`, the worktree's home, with the user's same-pane geometry edit. */ +export function setLayout( + request: TerminalLayoutSetRequest, + hostId: ExecutionHostId, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return traced( + 'set_layout', + commitPlan((home) => planTerminalLayoutSet(home, request), hostId, context), + (result) => (result.status === 'refused' ? result.reason : undefined) + ) +} + +/** The agent a tab launched is gone, so the tab stops naming it, in `hostId`, the worktree's home. */ +export function clearLaunchAgent( + store: Pick, + request: { worktreeId: string; tabId: string }, + hostId: ExecutionHostId +): void { + const session = store.getWorkspaceSession(hostId) + const tabs = session.tabsByWorktree[request.worktreeId] ?? [] + if (!tabs.some((tab) => tab.id === request.tabId && tab.launchAgent !== undefined)) { + return + } + const cleared = tabs.map((tab) => + tab.id === request.tabId ? { ...tab, launchAgent: undefined } : tab + ) + store.patchWorkspaceSession( + { tabsByWorktree: { ...session.tabsByWorktree, [request.worktreeId]: cleared } }, + hostId + ) +} + +/** A window's own sleeping-record changes, each written in its worktree's home partition. */ +export function commitSleepingRecords( + store: Pick< + Store, + 'getWorkspaceSessionHostIds' | 'getWorkspaceSession' | 'patchWorkspaceSession' + >, + changes: TerminalSleepingRecordChanges, + homeHostId: (worktreeId: string) => ExecutionHostId | null +): void { + for (const hostId of store.getWorkspaceSessionHostIds()) { + const isHome = (worktreeId: string): boolean => homeHostId(worktreeId) === hostId + const session = store.getWorkspaceSession(hostId) + const next = sleepLeaf(wakeLeaf(session, changes.wake, isHome), changes.sleep, isHome) + if (next !== session) { + store.patchWorkspaceSession( + { sleepingAgentSessionsByPaneKey: next.sleepingAgentSessionsByPaneKey }, + hostId + ) + } + } +} + +/** + * A window's pull from an SSH host, written only to that host's partition, its worktrees' home. + * Main's rows newer than the window's merge stand; returns whether any did, as the host lacks them. + */ +export function importPeerTopology( + store: Pick, + targetId: string, + session: WorkspaceSessionPatch, + publishedSince: (worktreeId: string) => boolean +): boolean { + return traced( + 'import_peer_topology', + () => { + if (Object.keys(session).length === 0) { + return { value: false, persist: false } + } + const hostId = toSshExecutionHostId(targetId) + const patch = overNewerMainRows(session, store.getWorkspaceSession(hostId), publishedSince) + store.patchWorkspaceSession(patch, hostId) + return { value: !structuralValuesEqual(patch, session) } + }, + () => undefined + )().value +} + +/** A sleeping agent's record lands beside its tab, in its worktree's home partition. */ +export function sleepLeaf( + session: WorkspaceSessionState, + records: Record, + isHome: (worktreeId: string) => boolean +): WorkspaceSessionState { + const current = session.sleepingAgentSessionsByPaneKey ?? {} + const held = Object.entries(records).filter( + ([paneKey, record]) => + isHome(record.worktreeId) && + session.tabsByWorktree?.[record.worktreeId]?.some( + (tab) => tab.id === parsePaneKey(paneKey)?.tabId + ) && + !structuralValuesEqual(current[paneKey], record) + ) + return held.length > 0 + ? { ...session, sleepingAgentSessionsByPaneKey: { ...current, ...Object.fromEntries(held) } } + : session +} + +/** Drops records from their worktree's home; the stored record names the worktree. */ +export function wakeLeaf( + session: WorkspaceSessionState, + paneKeys: string[], + isHome: (worktreeId: string) => boolean +): WorkspaceSessionState { + const current = session.sleepingAgentSessionsByPaneKey ?? {} + const woken = new Set( + paneKeys.filter( + (paneKey) => Object.hasOwn(current, paneKey) && isHome(current[paneKey].worktreeId) + ) + ) + if (woken.size === 0) { + return session + } + return { + ...session, + sleepingAgentSessionsByPaneKey: Object.fromEntries( + Object.entries(current).filter(([paneKey]) => !woken.has(paneKey)) + ) + } +} + /** * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. * Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code. @@ -84,7 +259,6 @@ type TopologyState = Pick< type TerminalTopologyCommitContext = { state: TopologyState - hostIds: () => ExecutionHostId[] getSession: (hostId: ExecutionHostId) => WorkspaceSessionState markDirty: ( domain: 'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases' @@ -102,25 +276,43 @@ function writeRestorable(read: () => V, write: (value: V) => void, next: V): } } +/** Writes the session `plan` makes of `hostId`'s; a null session writes nothing. */ +function commitPlan( + plan: (home: WorkspaceSessionState) => { result: T; session: WorkspaceSessionState | null }, + hostId: ExecutionHostId, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return () => { + const planned = plan(context.getSession(hostId)) + if (!planned.session) { + return { value: planned.result, persist: false } + } + const rollback = writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), + planned.session + ) + return { value: planned.result, rollback } + } +} + function commitLeafMove( request: TerminalLeafMoveRequest, + hostId: ExecutionHostId, context: TerminalTopologyCommitContext ): DurableProfileStateMutation { const { state } = context - const planned = planTerminalLeafMove( - context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })), - request - ) - if (planned.sessions.length === 0) { + const planned = planTerminalLeafMove(context.getSession(hostId), request) + if (!planned.session) { return { value: planned.result, persist: false } } - const restores = planned.sessions.map(({ hostId, session }) => + const restores = [ writeRestorable( () => context.getSession(hostId), (value) => context.markDirty(assignWorkspaceSessionPartition(state, hostId, value)), - session + planned.session ) - ) + ] const rekeyed = rekeyMovedLeafProfileRecords(state, request) if (rekeyed.ui) { restores.push( diff --git a/src/main/persistence/terminal-topology/terminal-topology-membership.ts b/src/main/persistence/terminal-topology/terminal-topology-membership.ts index 358a1a34736a..f1c7252ee408 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-membership.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-membership.ts @@ -22,6 +22,22 @@ export function advanceTerminalTopologyRevision( } } +/** + * Whether a membership change moves the repo's fence, as main's did: once a fence exists, any; + * before, only a split or a tab main itself admitted, so a window's own tabs keep a peer's pull + * importable. + */ +export function startsOrAdvancesTerminalFence( + session: WorkspaceSessionState, + worktreeId: string, + hostAdmitted: boolean +): boolean { + return ( + hostAdmitted || + (session.terminalTopologyRevisionByRepoId?.[getRepoIdFromWorktreeId(worktreeId)] ?? 0) > 0 + ) +} + /** * The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable — * `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts index 04a48908298f..7f091287069d 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -10,8 +10,13 @@ import type { TerminalPaneLayoutNode, TerminalTab } from '../../../shared/termin import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import type { TerminalSleepingRecordChanges } from '../../../shared/terminal-topology-slice' import { collectTerminalLeafOwners, isSameTerminal } from './terminal-owner-invariants' -import { closeLeafOrTab } from './terminal-topology-commit' +import { closeLeafOrTab, commitSleepingRecords } from './terminal-topology-commit' +import { + setRendererSession, + stageRendererSessionBeforeUnload +} from './terminal-renderer-presentation-save' import { emptyTerminalSessionProfile, FIXTURE_FOLDER_WORKTREE_ID, @@ -28,7 +33,7 @@ vi.mock('../../ssh/ssh-config-parser', () => ({ /** * Model test for terminal layout as main holds it. Seeded gesture sequences run against main's - * real Store, written the way today's desktop window writes them; a plain reference model says + * real Store, written the way the desktop window writes them; a plain reference model says * what main must hold after each one. Making the window a mirror of main changes who writes, not * this outcome, so every seed must stay green through that refactor. * Replay one seed: FUZZ_SEED= FUZZ_ITERATIONS=1 pnpm test . @@ -272,7 +277,10 @@ async function runSeed(seed: number): Promise { const window = new WindowSession(structuredClone(store.getWorkspaceSession())) const model: Model = new Map() const log: string[] = [] - const save = (): void => store.setWorkspaceSession(window.snapshot()) + const save = (): void => setRendererSession(store, window.snapshot()) + // The window's own sleeping-record changes commit to main; its saves no longer carry them. + const commitSleeping = (changes: TerminalSleepingRecordChanges): void => + commitSleepingRecords(store, changes, () => LOCAL_EXECUTION_HOST_ID) const panes = () => [...model].flatMap(([tabId, tab]) => [...tab.leaves].map(([leafId, pane]) => ({ tabId, leafId, tab, pane })) @@ -305,7 +313,7 @@ async function runSeed(seed: number): Promise { const reopen = async (stageQuit: boolean): Promise => { if (stageQuit) { - store.stageWorkspaceSessionBeforeUnload(window.snapshot()) + stageRendererSessionBeforeUnload(store, window.snapshot()) } store = await reopenTopologyStore(store, directory) window.session = structuredClone(store.getWorkspaceSession()) @@ -374,10 +382,12 @@ async function runSeed(seed: number): Promise { const { [leafId]: _closed, ...bindings } = layout.ptyIdsByLeafId ?? {} void _closed window.setLayout(tabId, withoutLeaf(layout.root!, leafId)!, bindings) - const { [`${tabId}:${leafId}`]: _record, ...sleeping } = + const { [`${tabId}:${leafId}`]: record, ...sleeping } = window.session.sleepingAgentSessionsByPaneKey ?? {} - void _record window.setSleeping(sleeping) + if (record) { + commitSleeping({ sleep: {}, wake: [`${tabId}:${leafId}`] }) + } tab.leaves.delete(leafId) await commitClose(tab.worktreeId, { kind: 'pane', tabId, leafId }) save() @@ -406,13 +416,16 @@ async function runSeed(seed: number): Promise { const pane = source.leaves.get(leafId)! log.push(`${op} ${sourceTabId}:${leafId} → ${targetTabId}`) await expect( - store.moveTerminalLeafToNewTab({ - worktreeId: source.worktreeId, - sourceTabId, - targetTabId, - leafId, - ptyId: pane.ptyId ?? null - }) + store.moveTerminalLeafToNewTab( + { + worktreeId: source.worktreeId, + sourceTabId, + targetTabId, + leafId, + ptyId: pane.ptyId ?? null + }, + 'local' + ) ).resolves.toEqual({ status: 'moved', ptyId: pane.ptyId ?? null }) // The window then shows what main moved, as the drag-out does today. const layout = window.session.terminalLayoutsByTabId[sourceTabId]! @@ -474,12 +487,18 @@ async function runSeed(seed: number): Promise { log.push(`${op} ${target.tabId}:${target.leafId}`) const paneKey = `${target.tabId}:${target.leafId}` const records = { ...window.session.sleepingAgentSessionsByPaneKey } + const record = sleepingRecord(target.tab.worktreeId, target.tabId, target.leafId) if (op === 'sleep') { - records[paneKey] = sleepingRecord(target.tab.worktreeId, target.tabId, target.leafId) + records[paneKey] = record } else { delete records[paneKey] } window.setSleeping(records) + commitSleeping( + op === 'sleep' + ? { sleep: { [paneKey]: record }, wake: [] } + : { sleep: {}, wake: [paneKey] } + ) target.pane.sleeping = op === 'sleep' save() break diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts index 32af5b46dd59..c66e01b8731f 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts @@ -13,6 +13,7 @@ import { OrcaRuntimeService } from './orca-runtime' import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' +import { setRendererSession } from '../persistence/terminal-topology/terminal-renderer-presentation-save' export const ACK_WORKTREE = 'repo1::/tmp/worktree' export const ACK_TAB = '11111111-1111-4111-8111-111111111111' @@ -160,7 +161,8 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { ACK_WORKTREE, ACK_TAB ) - store.setWorkspaceSession({ ...closed.session, terminalTopologyRevisionByRepoId: undefined }) + // The window's save after its close: presentation only, so main still holds the tab. + setRendererSession(store, { ...closed.session, terminalTopologyRevisionByRepoId: undefined }) await store.flushPendingOrThrowAsync() entered.resolve() await acknowledgement.promise diff --git a/src/main/runtime/headless-editor-tab-close-persistence.test.ts b/src/main/runtime/headless-editor-tab-close-persistence.test.ts new file mode 100644 index 000000000000..386e6f4221b2 --- /dev/null +++ b/src/main/runtime/headless-editor-tab-close-persistence.test.ts @@ -0,0 +1,127 @@ +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 { getDefaultWorkspaceSession } from '../../shared/constants' +import type { Tab } from '../../shared/tab-types' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../shared/workspace-session-state-types' +import { closeTestStores, createStore, makeRepo, testState } from '../persistence-test-harness' +import { HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR } from './mobile-session-editor-projection' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) + +const WT = 'r1::/repo' +const README = '/repo/README.md' +const MAIN = '/repo/main.ts' + +function openFile(filePath: string, overrides: Partial = {}): PersistedOpenFile { + return { + filePath, + relativePath: filePath.slice('/repo/'.length), + worktreeId: WT, + language: filePath.endsWith('.md') ? 'markdown' : 'typescript', + ...overrides + } +} + +function editorTab(id: string, filePath: string): Tab { + return { + id, + entityId: filePath, + groupId: 'group', + worktreeId: WT, + contentType: 'editor', + label: filePath, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +/** Two persisted editors, the README active and focused, as a migrated SSH host carries them. */ +function editorSession(readme: PersistedOpenFile = openFile(README)): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { [WT]: [readme, openFile(MAIN)] }, + unifiedTabs: { [WT]: [editorTab('editor-readme', README), editorTab('editor-main', MAIN)] }, + tabGroups: { + [WT]: [ + { + id: 'group', + worktreeId: WT, + activeTabId: 'editor-readme', + tabOrder: ['editor-readme', 'editor-main'], + recentTabIds: ['editor-main', 'editor-readme'] + } + ] + }, + activeTabTypeByWorktree: { [WT]: 'editor' }, + activeFileIdByWorktree: { [WT]: README }, + activeTabIdByWorktree: { [WT]: 'editor-readme' } + } +} + +async function headlessHost(session: WorkspaceSessionState) { + const store = createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + store.setWorkspaceSession(session) + const runtime = new OrcaRuntimeService(store) + const listed = await runtime.listMobileSessionTabs(`id:${WT}`) + expect(listed.tabs.map((tab) => tab.id)).toEqual(['editor-readme', 'editor-main']) + return { store, runtime } +} + +describe('closing a persisted editor tab on a windowless host', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-headless-editor-close-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('removes the file, its tab bar entry and every pointer to it from the saved session', async () => { + const { store, runtime } = await headlessHost(editorSession()) + + await runtime.closeMobileSessionTab(`id:${WT}`, 'editor-readme') + + const saved = store.getWorkspaceSession() + expect(saved.openFilesByWorktree?.[WT]?.map((file) => file.filePath)).toEqual([MAIN]) + expect(saved.unifiedTabs?.[WT]?.map((tab) => tab.id)).toEqual(['editor-main']) + expect(saved.tabGroups?.[WT]).toEqual([ + { + id: 'group', + worktreeId: WT, + activeTabId: null, + tabOrder: ['editor-main'], + recentTabIds: ['editor-main'] + } + ]) + expect(saved.activeFileIdByWorktree?.[WT]).toBeNull() + expect(saved.activeTabIdByWorktree?.[WT]).toBeNull() + const relisted = await runtime.listMobileSessionTabs(`id:${WT}`) + expect(relisted.tabs.map((tab) => tab.id)).toEqual(['editor-main']) + }) + + it('refuses to drop an unsaved draft unless the close is forced', async () => { + const dirty = openFile(README, { dirtyDraftContent: '# draft' }) + const { store, runtime } = await headlessHost(editorSession(dirty)) + + await expect(runtime.closeMobileSessionTab(`id:${WT}`, 'editor-readme')).rejects.toThrow( + HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR + ) + expect(store.getWorkspaceSession().openFilesByWorktree?.[WT]).toHaveLength(2) + + await runtime.closeMobileSessionTab(`id:${WT}`, 'editor-readme', { force: true }) + expect(store.getWorkspaceSession().openFilesByWorktree?.[WT]).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/headless-terminal-split-layout.ts b/src/main/runtime/headless-terminal-split-layout.ts index 8e326750f19a..e83e5e7d263b 100644 --- a/src/main/runtime/headless-terminal-split-layout.ts +++ b/src/main/runtime/headless-terminal-split-layout.ts @@ -2,6 +2,8 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' +import { splitLayoutLeaf } from '../persistence/terminal-topology/terminal-pane-placement-apply' + /** * Insert a newly split-off leaf into a terminal tab's persisted layout tree. * @@ -33,31 +35,17 @@ export function buildHeadlessTerminalSplitLayout( } return { ...node, first, second } } - // Why: PTY admission durably appends a fallback vertical leaf before this exact-direction commit. + // Why: PTY admission durably binds the leaf (placed, or grafted at the root) before this commit. const currentRoot = existing?.root ? removeProvisionalLeaf(existing.root) : null const existingRoot: TerminalPaneLayoutNode = currentRoot ?? { type: 'leaf', leafId: args.splitFromLeafId } - const insertSplit = (node: TerminalPaneLayoutNode): TerminalPaneLayoutNode => { - if (node.type === 'leaf') { - if (node.leafId !== args.splitFromLeafId) { - return node - } - return { - type: 'split', - direction: args.direction, - first: node, - second: { type: 'leaf', leafId: args.leafId } - } - } - return { ...node, first: insertSplit(node.first), second: insertSplit(node.second) } - } const ptyIdsByLeafId = { ...existing?.ptyIdsByLeafId } delete ptyIdsByLeafId[args.leafId] return { ...existing, - root: insertSplit(existingRoot), + root: splitLayoutLeaf(existingRoot, args.splitFromLeafId, args.leafId, args.direction), activeLeafId: args.leafId, expandedLeafId: existing?.expandedLeafId ?? null, ptyIdsByLeafId: { diff --git a/src/main/runtime/headless-terminal-tab-order-persistence.test.ts b/src/main/runtime/headless-terminal-tab-order-persistence.test.ts new file mode 100644 index 000000000000..0b61a9a9af41 --- /dev/null +++ b/src/main/runtime/headless-terminal-tab-order-persistence.test.ts @@ -0,0 +1,72 @@ +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 { getDefaultWorkspaceSession } from '../../shared/constants' +import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import { makeTerminalTab, TEST_LEAF_1, TEST_LEAF_2 } from '../persistence-session-fixtures' +import { closeTestStores, createStore, makeRepo, testState } from '../persistence-test-harness' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) + +const WT = 'r1::/repo' + +function leafLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +describe('a mobile reorder on a windowless host', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-headless-tab-order-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('persists the new terminal tab order, so a restart lists the tabs as moved', async () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WT]: [ + makeTerminalTab({ id: 'tab-a', worktreeId: WT, ptyId: 'pty-a', sortOrder: 0 }), + makeTerminalTab({ id: 'tab-b', worktreeId: WT, ptyId: 'pty-b', sortOrder: 1 }) + ] + }, + terminalLayoutsByTabId: { + 'tab-a': leafLayout(TEST_LEAF_1, 'pty-a'), + 'tab-b': leafLayout(TEST_LEAF_2, 'pty-b') + } + }) + const runtime = new OrcaRuntimeService(store) + const listed = await runtime.listMobileSessionTabs(`id:${WT}`) + expect(listed.tabGroups?.[0]?.tabOrder).toEqual(['tab-a', 'tab-b']) + + await runtime.moveMobileSessionTab(`id:${WT}`, { + kind: 'reorder', + tabId: 'tab-b', + targetGroupId: listed.tabGroups![0]!.id, + tabOrder: ['tab-b', 'tab-a'] + }) + store.flush() + + const sortOrders = (rows = store.getWorkspaceSession().tabsByWorktree[WT] ?? []) => + Object.fromEntries(rows.map((tab) => [tab.id, tab.sortOrder])) + expect(sortOrders()).toEqual({ 'tab-b': 0, 'tab-a': 1 }) + const restarted = new OrcaRuntimeService(createStore()) + const relisted = await restarted.listMobileSessionTabs(`id:${WT}`) + expect(relisted.tabGroups?.[0]?.tabOrder).toEqual(['tab-b', 'tab-a']) + }) +}) diff --git a/src/main/runtime/host-terminal-close-persistence-durability.test.ts b/src/main/runtime/host-terminal-close-persistence-durability.test.ts index 642edd6cf17b..b7a9d848fe5f 100644 --- a/src/main/runtime/host-terminal-close-persistence-durability.test.ts +++ b/src/main/runtime/host-terminal-close-persistence-durability.test.ts @@ -1,10 +1,6 @@ /** - * Seam: store.persistPtyBinding must not arm the repo's topology fence. An - * armed fence makes rebaseWorkspaceSessionTerminalMembership treat a later - * renderer close as a stale replay and restore the row, leaving the durable - * de-persist to ride the PTY exit — asynchronous, and able to fail outright. - * Safety is one guard away: advanceTopologyFence's - * `currentRevision <= 0 && !establishesSplitAuthority` early return. + * Seam: main's binding write advances the repo's topology fence on every membership change, and + * a window save cannot change membership, so a close is main's commit and stays durable. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' @@ -12,6 +8,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { createStore, testState } from '../persistence-test-harness' +import { setRendererSession } from '../persistence/terminal-topology/terminal-renderer-presentation-save' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' vi.mock('electron', () => ({ app: { getPath: () => testStateDirRef.dir, getName: () => 'orca', getVersion: () => '0.0.0' }, @@ -28,13 +26,11 @@ const WT = `${REPO_ID}::/tmp/wt-cli` const TAB = 'cli-tab-1' const LEAF = '11111111-1111-4111-8111-111111111111' const PTY = `${WT}@@a1b2c3d4` +const SECOND_LEAF = '22222222-2222-4222-8222-222222222222' +const INCARNATION = '33333333-3333-4333-8333-333333333333' -function rendererWriteWithout( - session: WorkspaceSessionState, - tabId: string -): WorkspaceSessionState { - // A renderer session write after closing `tabId`: the row is gone, and the - // renderer never writes the host-private topology fence. +function windowSaveWithout(session: WorkspaceSessionState, tabId: string): WorkspaceSessionState { + // A window save after hiding `tabId`: the row is gone, and the window never writes the fence. const next: WorkspaceSessionState = { ...session, tabsByWorktree: { @@ -42,7 +38,7 @@ function rendererWriteWithout( [WT]: (session.tabsByWorktree?.[WT] ?? []).filter((tab) => tab.id !== tabId) } } - delete (next as { terminalTopologyRevisionByRepoId?: unknown }).terminalTopologyRevisionByRepoId + delete next.terminalTopologyRevisionByRepoId return next } @@ -60,54 +56,41 @@ describe('host-created terminal close durability', () => { rmSync(testState.dir, { recursive: true, force: true }) }) - it('a host-created terminal does NOT push a fresh repo into host-authoritative membership', async () => { + it('a window save that omits a row main holds does not remove it', async () => { const store = await makeStore() await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - const session = store.getWorkspaceSession() - expect(session.tabsByWorktree?.[WT]?.map((t) => t.id)).toContain(TAB) - // advanceTopologyFence (store.ts:3284) deliberately declines to arm the - // fence while currentRevision <= 0 and no split authority is established. - // That is what keeps a renderer close authoritative for this repo. - expect(session.terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0).toBe(0) + setRendererSession(store, windowSaveWithout(store.getWorkspaceSession(), TAB)) + expect(store.getWorkspaceSession().tabsByWorktree?.[WT]?.map((t) => t.id)).toEqual([TAB]) }) - it('a renderer close write durably removes the row it persisted', async () => { + it("main's close stays durable through later stale window saves", async () => { const store = await makeStore() - await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) - expect(store.getWorkspaceSession().tabsByWorktree?.[WT] ?? []).toEqual([]) - }) - - it('stays removed with the PTY still connected and no exit ever delivered', async () => { - const store = await makeStore() - await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) - // Kill-failure shape: no retirement, no exit, just more renderer writes. + await store.persistPtyBinding({ + worktreeId: WT, + tabId: TAB, + leafId: LEAF, + ptyId: PTY, + incarnationId: INCARNATION + }) + const stale = structuredClone(store.getWorkspaceSession()) + store.setWorkspaceSession( + retireTerminalSurfaceFromPersistence(store.getWorkspaceSession(), { + worktreeId: WT, + parentTabId: TAB, + leafId: LEAF, + ptyId: PTY, + incarnationId: INCARNATION + }) + ) + // Kill-failure shape: no exit, just more window saves that still list the tab. for (let i = 0; i < 3; i += 1) { - store.setWorkspaceSession({ ...store.getWorkspaceSession() }) + setRendererSession(store, structuredClone(stale)) } expect(store.getWorkspaceSession().tabsByWorktree?.[WT] ?? []).toEqual([]) }) - - it('pre-existing host-authoritative membership rebases the close away (not ours)', async () => { - const store = await makeStore() - // A serve/SSH host arms the fence long before any CLI terminal exists. - store.setWorkspaceSession({ - ...store.getWorkspaceSession(), - terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } - }) - await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) - // Documents PRE-EXISTING behavior: with the fence already armed, a renderer - // write that omits a row is treated as a stale replay and the row survives - // until an exit-driven retirement advances the fence. This is independent of - // host-created terminals — it applies to every tab in such a repo. - expect(store.getWorkspaceSession().tabsByWorktree?.[WT]?.map((t) => t.id)).toContain(TAB) - }) }) -/** Pins the fence seam itself rather than spot-checking callers: a new - * fence-advancing path, or arming the fence from the create path, fails here. */ +/** Pins the fence seam itself: every membership change main's binding write makes advances it. */ describe('topology fence census', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-fence-census-')) @@ -118,10 +101,10 @@ describe('topology fence census', () => { rmSync(testState.dir, { recursive: true, force: true }) }) - it('no host-create binding shape arms a fresh repo fence', async () => { + it('every host-create binding shape advances a fresh repo fence', async () => { for (const [index, extra] of [ {}, - { incarnationId: '33333333-3333-4333-8333-333333333333' }, + { incarnationId: INCARNATION }, { startupCwd: '/tmp/wt-cli' } ].entries()) { const store = await makeStore() @@ -132,23 +115,21 @@ describe('topology fence census', () => { ptyId: `${PTY}-${index}`, ...extra }) - expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0).toBe(0) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1) } }) - it('a second pane in the same tab still does not arm the fence', async () => { + it('a second pane advances it; rebinding a held pane does not', async () => { const store = await makeStore() await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - await store.persistPtyBinding({ - worktreeId: WT, - tabId: TAB, - leafId: '22222222-2222-4222-8222-222222222222', - ptyId: `${PTY}-b` - }) - expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0).toBe(0) + const second = { worktreeId: WT, tabId: TAB, leafId: SECOND_LEAF, ptyId: `${PTY}-b` } + await store.persistPtyBinding(second) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(2) + await store.persistPtyBinding({ ...second, ptyId: `${PTY}-c` }) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(2) }) - it('an armed fence keeps climbing, so exit-driven retirement still outranks replays', async () => { + it('an armed fence keeps climbing', async () => { const store = await makeStore() store.setWorkspaceSession({ ...store.getWorkspaceSession(), diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts index e1138ddd20e8..61d997062c68 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' -import { - retireTerminalSurfaceFromPersistence, - sanitizeWorkspaceSessionTerminalRetirements -} from './mobile-session-terminal-persistence-retirement' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' const WORKTREE_ID = 'repo::/worktree' const REPO_ID = 'repo' @@ -240,163 +237,6 @@ describe('mobile session terminal persistence retirement', () => { expect(result.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1) }) - it('rebases stale writes onto durable host membership without retaining pane history', () => { - const staleSession = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [WORKTREE_ID]: [ - { - id: 'terminal', - ptyId: 'pty-left', - worktreeId: WORKTREE_ID, - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - terminal: { - root: { type: 'leaf' as const, leafId: 'left' }, - activeLeafId: 'left', - expandedLeafId: null, - ptyIdsByLeafId: { left: 'pty-left' } - } - }, - terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-a' } - } - const retired = retireTerminalSurfaceFromPersistence(staleSession, { - worktreeId: WORKTREE_ID, - parentTabId: 'terminal', - leafId: 'left', - ptyId: 'pty-left', - incarnationId: 'incarnation-a', - retiredAt: 42 - }) - - const afterStaleWrite = sanitizeWorkspaceSessionTerminalRetirements(staleSession, retired) - expect(afterStaleWrite.tabsByWorktree[WORKTREE_ID]).toEqual([]) - expect(afterStaleWrite.terminalSurfaceTombstonesByPaneKey).toEqual({}) - expect(afterStaleWrite.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1) - - const afterRestart = sanitizeWorkspaceSessionTerminalRetirements(staleSession, afterStaleWrite) - expect(afterRestart.tabsByWorktree[WORKTREE_ID]).toEqual([]) - - const untrustedReplacement = sanitizeWorkspaceSessionTerminalRetirements( - { - ...staleSession, - terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-b' } - }, - afterRestart - ) - expect(untrustedReplacement.tabsByWorktree[WORKTREE_ID]).toEqual([]) - expect(untrustedReplacement.terminalPtyIncarnationsByPaneKey).toBeUndefined() - }) - - it('rebases a host partition that no longer carries the closed web-terminal layout', () => { - const tabId = 'web-terminal-24aa462c-589c-45fa-b332-6aa233cd84cf' - const prior = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [WORKTREE_ID]: [ - { - id: tabId, - ptyId: 'pty-worker', - worktreeId: WORKTREE_ID, - title: 'Worker', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf' as const, leafId: 'worker' }, - activeLeafId: 'worker', - expandedLeafId: null, - ptyIdsByLeafId: { worker: 'pty-worker' } - } - }, - terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } - } - const incoming = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [WORKTREE_ID]: [] } - } - const partialIncoming: Partial = incoming - delete partialIncoming.terminalLayoutsByTabId - - const result = sanitizeWorkspaceSessionTerminalRetirements(incoming, prior) - - expect(result.tabsByWorktree[WORKTREE_ID]).toEqual(prior.tabsByWorktree[WORKTREE_ID]) - expect(result.terminalLayoutsByTabId[tabId]).toEqual(prior.terminalLayoutsByTabId[tabId]) - }) - - it('replays a legacy retirement when its host partition omits terminal layouts', () => { - const tabId = 'web-terminal-24aa462c-589c-45fa-b332-6aa233cd84cf' - const incoming = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [WORKTREE_ID]: [ - { - id: tabId, - ptyId: 'pty-worker', - worktreeId: WORKTREE_ID, - title: 'Worker', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalSurfaceTombstonesByPaneKey: { - [`${tabId}:worker`]: { - worktreeId: WORKTREE_ID, - parentTabId: tabId, - leafId: 'worker', - ptyId: 'pty-worker', - incarnationId: 'incarnation-worker', - retiredAt: 42 - } - } - } - const partialIncoming: Partial = incoming - delete partialIncoming.terminalLayoutsByTabId - - const result = sanitizeWorkspaceSessionTerminalRetirements(incoming, undefined) - - expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([]) - expect(result.terminalLayoutsByTabId).toEqual({}) - expect(result.terminalSurfaceTombstonesByPaneKey).toEqual({}) - expect(result.terminalTopologyRevisionByRepoId).toEqual({ [REPO_ID]: 1 }) - }) - - it('migrates legacy tombstones into one repo watermark', () => { - const stale = { - ...getDefaultWorkspaceSession(), - terminalSurfaceTombstonesByPaneKey: { - 'terminal:left': { - worktreeId: WORKTREE_ID, - parentTabId: 'terminal', - leafId: 'left', - ptyId: 'pty-left', - incarnationId: 'incarnation-a', - retiredAt: 42 - } - } - } - - const migrated = sanitizeWorkspaceSessionTerminalRetirements(stale, stale) - - expect(migrated.terminalSurfaceTombstonesByPaneKey).toEqual({}) - expect(migrated.terminalTopologyRevisionByRepoId).toEqual({ [REPO_ID]: 1 }) - }) - it('keeps retirement state proportional to repos across many worktrees and closed panes', () => { let session = getDefaultWorkspaceSession() for (let index = 0; index < 1_000; index += 1) { diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts index 25ed04c2f32c..34a0313e927e 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts @@ -6,7 +6,6 @@ import { retireLeavesFromTerminalLayout, type RetiredTerminalSurface } from './mobile-session-terminal-retirement' -import { rebaseWorkspaceSessionTerminalMembership } from './workspace-session-terminal-membership-authority' import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' function visibleTypeForContentType( @@ -220,38 +219,16 @@ export function retireTerminalSurfaceFromPersistence( ) } -export function sanitizeWorkspaceSessionTerminalRetirements( - incoming: WorkspaceSessionState, - prior: WorkspaceSessionState | undefined +/** Applies the close records an older build left for its next write to apply, then drops them. */ +export function applyLegacyTerminalSurfaceTombstones( + session: WorkspaceSessionState ): WorkspaceSessionState { - if ( - !prior?.terminalSurfaceTombstonesByPaneKey && - !incoming.terminalSurfaceTombstonesByPaneKey && - !prior?.terminalTopologyRevisionByRepoId - ) { - return incoming - } - const bindings = { - ...prior?.terminalPtyIncarnationsByPaneKey, - ...incoming.terminalPtyIncarnationsByPaneKey - } - const tombstones = { - ...prior?.terminalSurfaceTombstonesByPaneKey, - ...incoming.terminalSurfaceTombstonesByPaneKey - } - const hasLegacyTombstones = Object.keys(tombstones).length > 0 - let next: WorkspaceSessionState = { - ...incoming, - terminalPtyIncarnationsByPaneKey: hasLegacyTombstones - ? bindings - : incoming.terminalPtyIncarnationsByPaneKey, - terminalSurfaceTombstonesByPaneKey: tombstones + const tombstones = Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {}) + if (tombstones.length === 0) { + return session } - for (const tombstone of Object.values(tombstones)) { - next = retireTerminalSurfaceFromPersistence(next, tombstone) + return { + ...tombstones.reduce(retireTerminalSurfaceFromPersistence, session), + terminalSurfaceTombstonesByPaneKey: {} } - return rebaseWorkspaceSessionTerminalMembership( - { ...next, terminalSurfaceTombstonesByPaneKey: {} }, - prior - ) } diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index 57158256605a..4a14989efe9e 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -63,6 +63,7 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null, getLeaves: (ptyId) => this.getLeavesForPty(ptyId), getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)), + isStopRequested: (ptyId) => this.isPtyStopRequested(ptyId), getDisplayTitle: (pty) => getLatestPtyTitle(this.getPtyDisplayRecord(pty)), replayPersistedSurface: (pty, tabId, paneKey) => recordPtySurface(pty, tabId, paneKey, SURFACE_CLAIM_WITHOUT_STANDING), diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 2bfeaec2adde..b2f5c1af3d50 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -184,7 +184,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU return finishCommittedClose() } if (closingWholeParent && this.notifier?.closeTerminalTab) { - // The renderer flush can rebase its omission; the host commits the acknowledged identity. + // The window's close commits through main; the host also commits the acknowledged identity. const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, tab.parentTabId) // Wait for the renderer's pin guard, retirement and forced session flush. const win = this.getAvailableAuthoritativeWindow() diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 8c4ed9ac7bf3..01b39c76b889 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -169,7 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), - placement: dependencies.runtimeNewTabPlacement(), + placement: dependencies.runtimeNewTabPlacement(launchOpts.title, presentation), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS diff --git a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts index 78c31b578346..f28e9263e674 100644 --- a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts +++ b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts @@ -10,6 +10,7 @@ import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' import { cloneTerminalLayoutSnapshot } from './mobile-session-layout-projection' +import { sameTerminalLeafSet } from '../persistence/terminal-topology/terminal-layout-set' export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWithCloseHeadlessMobileTerminalTab { protected persistHeadlessSessionTabProps( @@ -130,25 +131,23 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi if (!existing) { return undefined } - const candidate = { - ...session, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - ...cloneTerminalLayoutSnapshot(existing), - root: args.root ?? existing.root, - expandedLeafId: args.expandedLeafId, - ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), - ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) - } - } + const root = args.root ?? existing.root + // A tree with other panes is stale, and its focus and titles may name panes main no longer holds. + if (!sameTerminalLeafSet(existing.root, root)) { + return existing + } + const layout = { + ...cloneTerminalLayoutSnapshot(existing), + root, + expandedLeafId: args.expandedLeafId, + ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), + ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) } - this.setWorkspaceSessionForWorktree(worktreeId, candidate) - // Why: persistence may reject stale membership while accepting its metadata; publish only that rebased layout. - return ( - this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId[args.tabId] ?? - candidate.terminalLayoutsByTabId[args.tabId] - ) + this.setWorkspaceSessionForWorktree(worktreeId, { + ...session, + terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId, [args.tabId]: layout } + }) + return layout } protected applyHeadlessTerminalPaneLayoutToSnapshot( diff --git a/src/main/runtime/orca-runtime-terminal-close-records.test.ts b/src/main/runtime/orca-runtime-terminal-close-records.test.ts index 28bb3e62d2e8..15b2869c66b9 100644 --- a/src/main/runtime/orca-runtime-terminal-close-records.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-records.test.ts @@ -20,6 +20,7 @@ import { makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' +import { setRendererSession } from '../persistence/terminal-topology/terminal-renderer-presentation-save' const SSH_REPO_ID = 'ssh-repo' const SSH_HOST_ID = 'ssh:target-1' @@ -89,7 +90,7 @@ describe('close records', () => { target: { kind: 'tab', tabId: TAB_ID }, reason }) - store.setWorkspaceSession(rendererSave(store.getWorkspaceSession())) + setRendererSession(store, rendererSave(store.getWorkspaceSession())) const reloaded = (await reload()).getWorkspaceSession() expect(reloaded.tabsByWorktree[WORKTREE_ID]).toEqual([]) @@ -101,7 +102,7 @@ describe('close records', () => { } ) - // The store keeps main's map only when a write omits it; main's own writes carry it and win. + // A window save never carries the records; main's own writes do and win. it("keeps main's own record writes across later store writes", async () => { const { store, runtime } = createPersistedRuntime() @@ -113,7 +114,7 @@ describe('close records', () => { worktreeId: WORKTREE_ID, target: { kind: 'tab', tabId: LATE_TAB_ID } }) - store.setWorkspaceSession(rendererSave(store.getWorkspaceSession())) + setRendererSession(store, rendererSave(store.getWorkspaceSession())) expect( Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}).sort() diff --git a/src/main/runtime/orca-runtime-terminal-retirement.test.ts b/src/main/runtime/orca-runtime-terminal-retirement.test.ts index d6f70fd4c6e0..9f0df34c4ef1 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement.test.ts @@ -7,7 +7,6 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { sanitizeWorkspaceSessionTerminalRetirements } from './mobile-session-terminal-persistence-retirement' import { OrcaRuntimeService } from './orca-runtime' const WORKTREE_ID = 'repo::/worktree' @@ -417,7 +416,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { expect(tabs[0]).toMatchObject({ type: 'terminal', ptyId: 'pty-right' }) }) - it('publishes the host-rebased layout after a stale client pane update', async () => { + it('keeps the host layout after a stale client pane update with other panes', async () => { let session = makePersistedSplitSession() session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-right' session.terminalLayoutsByTabId.tab = { @@ -433,7 +432,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { runtimeStore({ getWorkspaceSession: () => session, setWorkspaceSession: (incoming: WorkspaceSessionState) => { - session = sanitizeWorkspaceSessionTerminalRetirements(incoming, session) + session = incoming } }) ) diff --git a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts index 98e22b90b568..b09e86173f5b 100644 --- a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts +++ b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts @@ -19,10 +19,8 @@ import { createHarness, makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' -import { - retireTerminalSurfaceFromPersistence, - sanitizeWorkspaceSessionTerminalRetirements -} from './mobile-session-terminal-persistence-retirement' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' +import { mergeRendererPresentationSave } from '../persistence/terminal-topology/terminal-renderer-presentation-save' import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const splitLayout = { @@ -247,9 +245,10 @@ describe('CLI close of one pane in a split tab', () => { const receipt = await harness.runtime.closeTerminal(terminal.handle) // A stale renderer save that still lists the pane can neither restore it nor drop the sibling. - const saved = sanitizeWorkspaceSessionTerminalRetirements( + const saved = mergeRendererPresentationSave( { ...harness.getSession(), terminalLayoutsByTabId: { [TAB_ID]: splitLayout } }, - harness.getSession() + harness.getSession(), + 'local' ) expect(saved.tabsByWorktree[WORKTREE_ID]).toEqual([expect.objectContaining({ id: TAB_ID })]) expect(saved.terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts index be1d4c8175c4..af483ec5514b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts @@ -254,6 +254,13 @@ describe('OrcaRuntimeService', () => { it('persists a headless pane layout (ratio/expand) so it survives a cold rehydrate', async () => { const session = makeWorkspaceSessionWithHeadlessTerminal() + // A client edits the geometry of panes main holds; it cannot add one. + session.terminalLayoutsByTabId['host-tab']!.root = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, + second: { type: 'leaf', leafId: 'leaf-2' } + } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService(runtimeStore as never) @@ -288,6 +295,28 @@ describe('OrcaRuntimeService', () => { }) }) + it("keeps main's whole layout when a client's pane tree is stale", async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore as never) + const before = structuredClone(getSession().terminalLayoutsByTabId['host-tab']) + + await runtime.updateMobileSessionPaneLayout(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, + second: { type: 'leaf', leafId: 'leaf-gone' } + }, + expandedLeafId: 'leaf-gone', + chatLeafId: 'leaf-gone', + titlesByLeafId: { 'leaf-gone': 'Pane B' } + }) + + expect(getSession().terminalLayoutsByTabId['host-tab']).toEqual(before) + }) + it('persists headless tab color + pin and surfaces them through a cold rehydrate', async () => { const session = makeWorkspaceSessionWithHeadlessTerminal() const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts index bd1c09dded0e..3f394dbde905 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts @@ -250,6 +250,65 @@ describe('OrcaRuntimeService', () => { ) }) + // The title is presentation for the tab its reveal shows, so main's row carries it from the spawn. + it("names a titled create's tab in its placement, unless the create is in the background", async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-titled' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { title: 'cli-made' }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + title: 'worker-1', + presentation: 'background' + }) + + expect(spawn.mock.calls.map(([args]) => args.placement)).toEqual([ + { kind: 'new-tab', row: { customTitle: 'cli-made' } }, + { kind: 'new-tab' } + ]) + }) + + // As before the topology core: a background worker's title names its terminal, never a desktop tab. + it("keeps a background create's title off every tab, revealing none", async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-worker' }) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-worker' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + title: 'worker-1', + presentation: 'background' + }) + + expect(created).toMatchObject({ title: 'worker-1', surface: 'background' }) + expect(revealTerminalSession).not.toHaveBeenCalled() + expect(JSON.stringify(spawn.mock.calls[0]?.[0])).not.toContain('worker-1') + }) + // Why (flipped by the aug20 "windows 2" incident): #8646 scoped the persisted // binding to windowless promotion, which left a host-initiated terminal on a // host running the full app with neither a persisted tab nor runtime diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts index b9f605ccf910..0d404c286604 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts @@ -198,6 +198,59 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('terminal_orphan_surface_retired') }) + // A desktop pane close commits before its PTY exits; a paired client sees that PTY as an orphan. + it('refuses to adopt the PTY of a pane closed while its stop is pending', async () => { + const makeRuntime = (): OrcaRuntimeService => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] }, + terminalTopologyRevisionByRepoId: { [TEST_REPO_ID]: 7 } + }) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-closing', + incarnationId: 'inc-closing', + terminalHandle: 'term_closing', + title: 'shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + wslDistro: null + } + ] + }) + return runtime + } + const request = { + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 7, + claims: [ + { + terminal: 'term_closing', + ptyId: 'pty-closing', + incarnationId: 'inc-closing', + tabId: 'tab-closing', + leafId: HEADLESS_LEAF_ID + } + ] + } + const closing = makeRuntime() + closing.markPtyStopRequested('pty-closing') + + await expect(closing.adoptTerminalOrphans(request)).rejects.toThrow( + 'terminal_orphan_surface_retired' + ) + await expect(makeRuntime().adoptTerminalOrphans(request)).resolves.toMatchObject({ + adopted: true + }) + }) + it('keeps orphaned list and show writability aligned with the send gate', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5dc601b844e6..c6cfcb02914d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14,6 +14,9 @@ import type { AgentSessionRecordStore } from './agent-session-record-store' import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' import { createAgentLaunchRecordWarmupGate } from './agent-launch-record-warmup-gate' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' +import { TerminalTopologyPublisher } from './terminal-topology-publisher' class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { constructor(...args: ConstructorParameters) { @@ -23,6 +26,27 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { // module registers the generation bump at load; a headless host never loads it. registerDetectedWorktreeScanInvalidation() registerWorktreeChangeInvalidator((repoId) => this.invalidateWorktreeCatalog(repoId)) + this.store?.onWorkspaceSessionWritten?.(() => this.terminalTopology.markDirty()) + } + + private readonly terminalTopology = new TerminalTopologyPublisher( + () => this.workspaceSessions.getTerminalTopologyOwners(), + (slice) => this.notifier?.terminalTopologyChanged?.(slice) + ) + + /** Every worktree's current terminal layout, for a window that missed pushes while loading. */ + getTerminalTopologySlices(): TerminalTopologySlice[] { + return this.terminalTopology.snapshot() + } + + /** Where main writes a worktree's terminal layout: the partition its push projects. */ + getTerminalTopologyHomeHostId(worktreeId: string): ExecutionHostId | null { + return this.workspaceSessions.getTerminalTopologyHomeHostId(worktreeId) + } + + /** The publishSeq of the push holding every topology write made so far, for a reply to name. */ + settleTerminalTopology(worktreeId?: string): number | undefined { + return this.terminalTopology.settle(worktreeId) } /** Whether a window owns the layout and can show a launch's tab ahead of its process. */ diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index d13da3976f1d..fea225e47eb8 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/agent-launch-tab-publication' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' +import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' @@ -170,4 +171,5 @@ export type RuntimeNotifier = { browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void browserRemoteViewersChanged?(browserPageId: string, hasRemoteViewers: boolean): void clientHostedBrowserRowsChanged?(event: ClientHostedBrowserRowsEvent): void + terminalTopologyChanged?(slice: TerminalTopologySlice): void } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index b25168334c67..6d68853a6956 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -46,6 +46,7 @@ export type RuntimeStore = { getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] + onWorkspaceSessionWritten?: Store['onWorkspaceSessionWritten'] setWorkspaceSession?: Store['setWorkspaceSession'] runDurableMutation?: Store['runDurableMutation'] flushOrThrow?: Store['flushOrThrow'] diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index 610cc7ae6dd6..8de75c62af17 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -20,6 +20,8 @@ type RuntimeTerminalOrphanAdoptionPorts = { getPty: (handle: string) => RuntimePtyWorktreeRecord | null getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[] getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined + /** Main was asked to stop the PTY, as a pane close does before its process exits. */ + isStopRequested: (ptyId: string) => boolean /** The title display surfaces show for the PTY, which an adopted tab persists. */ getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null /** Replays a binding the session already held: names the pane without claiming the graph holds it. */ @@ -189,7 +191,9 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { throw new Error('terminal_orphan_surface_occupied') } // Why the close record too: it outlives a host restart, which the client's retirement proofs do not. + // Why the stop: a closed pane's PTY outlives the close until it exits, and is not an orphan. if ( + ports.isStopRequested(claim.ptyId) || session.terminalSurfaceTombstonesByPaneKey?.[paneKey] || hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, claim.tabId) ) { diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts index ae7503c712bf..b2d0a168e938 100644 --- a/src/main/runtime/runtime-terminal-spawn-placement.ts +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -1,9 +1,18 @@ import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types' +import type { RuntimeTerminalPresentation } from '../../shared/runtime-terminal-contracts' -/** A main-side create always opens its own tab. */ -export function runtimeNewTabPlacement(): TerminalPanePlacement { - return { kind: 'new-tab' } +/** + * A main-side create always opens its own tab. Its title names the tab its reveal shows; a + * background create reveals none, so it names none. + */ +export function runtimeNewTabPlacement( + title: string | null | undefined, + presentation: RuntimeTerminalPresentation | undefined +): TerminalPanePlacement { + return title && presentation !== 'background' + ? { kind: 'new-tab', row: { customTitle: title } } + : { kind: 'new-tab' } } export function runtimeSplitPlacement( diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index f6bb00141699..f8d5e3608ea7 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -10,7 +10,20 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { workspaceSessionPartitionHostId } from '../../shared/workspace-session-partition-owner' import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { isTerminalOwnerPartition } from '../persistence/terminal-topology/terminal-topology-membership' import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalTopologyOwners } from './terminal-topology-publisher' + +const FOLDER_WORKSPACE_CONNECTION_AMBIGUOUS = 'folder_workspace_connection_ambiguous' + +/** The repo a workspace key belongs to in the catalog; null for a folder workspace. */ +function catalogRepoId(worktreeId: string): string | null { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return null + } + return getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId) +} type RuntimeWorkspaceSessionDependencies = { getStore: () => RuntimeStore | null @@ -46,13 +59,25 @@ export class RuntimeWorkspaceSessionController { const connectionId = this.deps.resolveFolderConnectionId(workspace) return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID } - const resolvedWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId - const repo = store?.getRepo?.(getRepoIdFromWorktreeId(resolvedWorktreeId)) + const repoId = catalogRepoId(worktreeId) + const repo = repoId ? store?.getRepo?.(repoId) : undefined return repo ? workspaceSessionPartitionHostId(getRepoExecutionHostId(repo)) : LOCAL_EXECUTION_HOST_ID } + /** An ambiguous folder's owner is unverifiable, not absent; any other failure propagates. */ + private tryGetPreferredHostId(worktreeId: string, store: RuntimeStore): ExecutionHostId | null { + try { + return this.getPreferredHostId(worktreeId, store) + } catch (error) { + if (error instanceof Error && error.message === FOLDER_WORKSPACE_CONNECTION_AMBIGUOUS) { + return null + } + throw error + } + } + private resolveHostId( worktreeId: string, preferredHostId: ExecutionHostId, @@ -140,6 +165,59 @@ export class RuntimeWorkspaceSessionController { return worktreeIds } + /** The one `local`/`ssh:` partition a worktree's terminal rows live in; null when unresolved or another server's. */ + getTerminalTopologyHomeHostId(worktreeId: string): ExecutionHostId | null { + const store = this.deps.getStore() + const hostId = store ? this.resolveTerminalTopologyHome(worktreeId, store) : null + return hostId && isTerminalOwnerPartition(hostId) ? hostId : null + } + + /** The worktree's home partition; null when unverifiable rather than absent. */ + private resolveTerminalTopologyHome( + worktreeId: string, + store: RuntimeStore + ): ExecutionHostId | null { + const repoId = catalogRepoId(worktreeId) + if (!repoId || store.getRepo?.(repoId)) { + return this.tryGetPreferredHostId(worktreeId, store) + } + // An uncatalogued repo's home is the one partition holding its rows; rows in two are ambiguous. + const holders = (store.getWorkspaceSessionHostIds?.() ?? []).filter( + (hostId) => + isTerminalOwnerPartition(hostId) && + (store.getWorkspaceSession?.(hostId).tabsByWorktree[worktreeId]?.length ?? 0) > 0 + ) + return holders.length > 1 ? null : (holders[0] ?? LOCAL_EXECUTION_HOST_ID) + } + + /** + * Worktrees with terminal rows in their home partition. `runtime:` homes are another server's; + * a home that can't be resolved (a missing or ambiguous folder, an uncatalogued repo with rows in + * two partitions) maps to null. + */ + getTerminalTopologyOwners(): TerminalTopologyOwners { + const owners: TerminalTopologyOwners = new Map() + const store = this.deps.getStore() + if (!store?.getWorkspaceSession) { + return owners + } + for (const hostId of store.getWorkspaceSessionHostIds?.() ?? []) { + if (!isTerminalOwnerPartition(hostId)) { + continue + } + const session = store.getWorkspaceSession(hostId) + for (const worktreeId of Object.keys(session.tabsByWorktree ?? {})) { + const homeHostId = this.resolveTerminalTopologyHome(worktreeId, store) + if (!homeHostId) { + owners.set(worktreeId, null) + } else if (homeHostId === hostId) { + owners.set(worktreeId, { hostId, session }) + } + } + } + return owners + } + getHydrationTargets(includeAllPersistedWorktrees: boolean): Map { const store = this.deps.getStore() if (!store) { diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts index b89bc709e5f2..4ee52f772221 100644 --- a/src/main/runtime/terminal-intentional-stops.ts +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -3,7 +3,7 @@ import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/vis /** * Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit: * - `reversible`: sleep or hibernation; the binding is the wake hint. - * - `replaced`: a restart stops the old process so a new one can take the pane. + * - `replaced`: a new process takes the pane (a restart, or a respawn over an absent one). */ export type TerminalIntentionalStopKind = 'reversible' | 'replaced' diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts index 636834a6178e..26993c16777f 100644 --- a/src/main/runtime/terminal-surface-close.ts +++ b/src/main/runtime/terminal-surface-close.ts @@ -77,7 +77,7 @@ function readLayoutOwnerLeafIds(copies: TerminalCloseLayoutCopies): readonly str /** * The only place main turns a pane close into its tab's close: `last-pane` sends callers down * the tab path (and its renderer pin guard); anything else closes that pane or nothing. - * D1 (main as the single layout writer) collapses the owner read to main's session layout alone. + * Main is the only layout writer, so the owner's copy is main's session layout alone. */ export function resolveTerminalCloseTarget( target: TerminalSurfaceCloseTarget, @@ -164,8 +164,7 @@ export type TerminalSurfaceCloseOptions = { allowMissing?: boolean force?: boolean reason?: RuntimeSessionTabCloseReason - /** The desktop renderer's own close: its layout owner already removed the tab. Goes away with - * D1, once main owns the terminal layout. */ + /** The desktop renderer's own close: its layout owner already removed the tab. */ closedByLayoutOwner?: boolean } diff --git a/src/main/runtime/terminal-topology-owners.test.ts b/src/main/runtime/terminal-topology-owners.test.ts new file mode 100644 index 000000000000..28be2e0d8d80 --- /dev/null +++ b/src/main/runtime/terminal-topology-owners.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller' + +const LOCAL_WT = 'repo-local::/wt' +const SSH_WT = 'repo-ssh::/remote/wt' +const RUNTIME_WT = 'repo-runtime::/srv/wt' +const AMBIGUOUS_FOLDER = 'folder:mixed' +const SSH_FOLDER = 'folder:remote' +const UNCATALOGUED_WT = 'repo-gone::/wt' + +function sessionWith(worktreeIds: string[]): WorkspaceSessionState { + const session = getDefaultWorkspaceSession() + for (const worktreeId of worktreeIds) { + session.tabsByWorktree[worktreeId] = [ + { + id: `tab-${worktreeId}`, + worktreeId, + ptyId: null, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + return session +} + +function controllerFor(sessions: Map) { + const repos = [ + { id: 'repo-local' }, + { id: 'repo-ssh', connectionId: 'c1' }, + { id: 'repo-runtime', executionHostId: 'runtime:e1' } + ] + const folders = [ + { id: 'mixed', folderPath: '/mixed' }, + { id: 'remote', folderPath: '/remote', executionHostId: 'ssh:c1' } + ] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller reads only repos, folder workspaces and workspace sessions. + const store = { + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getFolderWorkspaces: () => folders, + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId: ExecutionHostId) => + sessions.get(hostId) ?? getDefaultWorkspaceSession() + } as never + return new RuntimeWorkspaceSessionController({ + getStore: () => store, + resolveFolderConnectionId: (workspace: FolderWorkspace) => { + if (workspace.id === 'mixed') { + throw new Error('folder_workspace_connection_ambiguous') + } + return null + }, + hasRuntimeOwnedPtyCandidate: () => false + }) +} + +function ownersFor(sessions: Map) { + return controllerFor(sessions).getTerminalTopologyOwners() +} + +describe('terminal topology owners', () => { + it('maps each worktree to its home partition only', () => { + const local = sessionWith([LOCAL_WT, FLOATING_TERMINAL_WORKTREE_ID, SSH_WT]) + const ssh = sessionWith([SSH_WT, SSH_FOLDER]) + const owners = ownersFor( + new Map([ + ['local', local], + ['ssh:c1', ssh] + ]) + ) + + expect(owners).toEqual( + new Map([ + [LOCAL_WT, { hostId: 'local', session: local }], + [FLOATING_TERMINAL_WORKTREE_ID, { hostId: 'local', session: local }], + [SSH_WT, { hostId: 'ssh:c1', session: ssh }], + [SSH_FOLDER, { hostId: 'ssh:c1', session: ssh }] + ]) + ) + }) + + it('leaves out runtime homes and runtime partitions', () => { + const owners = ownersFor( + new Map([ + ['local', sessionWith([RUNTIME_WT])], + ['runtime:e1', sessionWith([RUNTIME_WT, LOCAL_WT])] + ]) + ) + + expect(owners.size).toBe(0) + }) + + it('leaves out rows only in a partition that is not the home', () => { + const owners = ownersFor(new Map([['local', sessionWith([SSH_WT])]])) + + expect(owners.has(SSH_WT)).toBe(false) + }) + + it('marks an ambiguous folder unresolved without losing the other worktrees', () => { + const local = sessionWith([AMBIGUOUS_FOLDER, LOCAL_WT]) + const owners = ownersFor(new Map([['local', local]])) + + expect(owners).toEqual( + new Map([ + [AMBIGUOUS_FOLDER, null], + [LOCAL_WT, { hostId: 'local', session: local }] + ]) + ) + }) + + it('homes an uncatalogued repo in the one partition holding its rows, and none when two do', () => { + const ssh = sessionWith([UNCATALOGUED_WT]) + const controller = controllerFor(new Map([['ssh:c1', ssh]])) + expect(controller.getTerminalTopologyOwners()).toEqual( + new Map([[UNCATALOGUED_WT, { hostId: 'ssh:c1', session: ssh }]]) + ) + expect(controller.getTerminalTopologyHomeHostId(UNCATALOGUED_WT)).toBe('ssh:c1') + + const local = sessionWith([UNCATALOGUED_WT]) + expect(ownersFor(new Map([['local', local]]))).toEqual( + new Map([[UNCATALOGUED_WT, { hostId: 'local', session: local }]]) + ) + expect(controllerFor(new Map()).getTerminalTopologyHomeHostId(UNCATALOGUED_WT)).toBe('local') + + const both = controllerFor( + new Map([ + ['local', sessionWith([UNCATALOGUED_WT])], + ['ssh:c1', sessionWith([UNCATALOGUED_WT])] + ]) + ) + expect(both.getTerminalTopologyOwners()).toEqual(new Map([[UNCATALOGUED_WT, null]])) + expect(both.getTerminalTopologyHomeHostId(UNCATALOGUED_WT)).toBeNull() + }) + + it('resolves one home for a layout write, and none for a runtime or ambiguous worktree', () => { + const controller = controllerFor(new Map([['local', sessionWith([SSH_WT])]])) + + expect(controller.getTerminalTopologyHomeHostId(LOCAL_WT)).toBe('local') + expect(controller.getTerminalTopologyHomeHostId(SSH_WT)).toBe('ssh:c1') + expect(controller.getTerminalTopologyHomeHostId(SSH_FOLDER)).toBe('ssh:c1') + expect(controller.getTerminalTopologyHomeHostId(RUNTIME_WT)).toBeNull() + expect(controller.getTerminalTopologyHomeHostId(AMBIGUOUS_FOLDER)).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-topology-projection.test.ts b/src/main/runtime/terminal-topology-projection.test.ts index 34e3e0211a77..3eb8b1201177 100644 --- a/src/main/runtime/terminal-topology-projection.test.ts +++ b/src/main/runtime/terminal-topology-projection.test.ts @@ -84,7 +84,7 @@ function splitAgentAndShellSession(): WorkspaceSessionState { } describe('projectTerminalTopologySlice', () => { - it('keeps topology and creation fields of a split agent+shell tab and drops presentation', () => { + it("keeps a split agent+shell tab's row to topology and creation fields; its saved presentation rides apart", () => { const session = splitAgentAndShellSession() const before = structuredClone(session) @@ -102,6 +102,7 @@ describe('projectTerminalTopologySlice', () => { createdAt: 10 } ], + presentation: { 'tab-agent': { customTitle: 'presentation only', color: 'red' } }, layouts: { 'tab-agent': { root: session.terminalLayoutsByTabId['tab-agent']!.root, @@ -145,6 +146,7 @@ describe('projectTerminalTopologySlice', () => { tabs: [ { id: 'tab-f', worktreeId: folder, ptyId: null, createdAt: 10, startupCwd: '/tmp/f/sub' } ], + presentation: { 'tab-f': { customTitle: null, color: null } }, layouts: {}, sleeping: {} }) diff --git a/src/main/runtime/terminal-topology-projection.ts b/src/main/runtime/terminal-topology-projection.ts index 6c07956ddfc2..517c2f62ee2f 100644 --- a/src/main/runtime/terminal-topology-projection.ts +++ b/src/main/runtime/terminal-topology-projection.ts @@ -11,7 +11,7 @@ import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' export type UnsequencedTerminalTopologySlice = Omit -function projectTabRow(tab: TerminalTab): TerminalTopologyTabRow { +export function projectTabRow(tab: TerminalTab): TerminalTopologyTabRow { const { launchAgent, defaultTitle, shellOverride, startupCwd, forceHostRuntime } = tab const { quickCommandLabel } = tab return { @@ -28,13 +28,33 @@ function projectTabRow(tab: TerminalTab): TerminalTopologyTabRow { } } +export type SleepingRecordsByWorktree = Map> + +/** One pass over a partition's sleeping-agent records, so projecting each worktree needn't rescan. */ +export function groupSleepingRecordsByWorktree( + session: WorkspaceSessionState +): SleepingRecordsByWorktree { + const grouped: SleepingRecordsByWorktree = new Map() + for (const [paneKey, record] of Object.entries(session.sleepingAgentSessionsByPaneKey ?? {})) { + const records = grouped.get(record.worktreeId) ?? {} + records[paneKey] = record + grouped.set(record.worktreeId, records) + } + return grouped +} + /** Pure; reads only the worktree's own rows from the partition that owns it. */ export function projectTerminalTopologySlice( session: WorkspaceSessionState, hostId: ExecutionHostId, - worktreeId: string + worktreeId: string, + sleepingByWorktree: SleepingRecordsByWorktree = groupSleepingRecordsByWorktree(session) ): UnsequencedTerminalTopologySlice { - const tabs = (session.tabsByWorktree?.[worktreeId] ?? []).map(projectTabRow) + const rows = session.tabsByWorktree?.[worktreeId] ?? [] + const tabs = rows.map(projectTabRow) + const presentation = Object.fromEntries( + rows.map(({ id, customTitle, color }) => [id, { customTitle, color }]) + ) const layouts: Record = {} for (const tab of tabs) { const layout = session.terminalLayoutsByTabId?.[tab.id] @@ -47,19 +67,14 @@ export function projectTerminalTopologySlice( ...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {}) } } - const sleeping: Record = {} - for (const [paneKey, record] of Object.entries(session.sleepingAgentSessionsByPaneKey ?? {})) { - if (record.worktreeId === worktreeId) { - sleeping[paneKey] = record - } - } return { hostId, worktreeId, revision: session.terminalTopologyRevisionByRepoId?.[getRepoIdFromWorktreeId(worktreeId)] ?? 0, tabs, + presentation, layouts, - sleeping + sleeping: sleepingByWorktree.get(worktreeId) ?? {} } } @@ -69,5 +84,5 @@ export function emptyTerminalTopologySlice( worktreeId: string, revision: number ): UnsequencedTerminalTopologySlice { - return { hostId, worktreeId, revision, tabs: [], layouts: {}, sleeping: {} } + return { hostId, worktreeId, revision, tabs: [], presentation: {}, layouts: {}, sleeping: {} } } diff --git a/src/main/runtime/terminal-topology-publisher.test.ts b/src/main/runtime/terminal-topology-publisher.test.ts index 54e7a0fa4ba1..6d080d4fce4b 100644 --- a/src/main/runtime/terminal-topology-publisher.test.ts +++ b/src/main/runtime/terminal-topology-publisher.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { TEST_LEAF_1 } from '../persistence-session-fixtures' -import { - TerminalTopologyPublisher, - type WorkspaceSessionOwner -} from './terminal-topology-publisher' +import type { TerminalSessionPartition } from '../persistence/terminal-topology/terminal-topology-membership' +import { TerminalTopologyPublisher } from './terminal-topology-publisher' const WT = 'repo-1::/tmp/wt-a' const WT_B = 'repo-1::/tmp/wt-b' @@ -39,12 +38,13 @@ function sessionWith(worktreeIds: string[]): WorkspaceSessionState { function harness(initial: WorkspaceSessionState) { let session = initial + const unresolved = new Set() const readOwners = vi.fn( () => - new Map( + new Map( Object.keys(session.tabsByWorktree).map((worktreeId) => [ worktreeId, - { hostId: 'local', session } + unresolved.has(worktreeId) ? null : { hostId: 'local', session } ]) ) ) @@ -72,6 +72,9 @@ function harness(initial: WorkspaceSessionState) { }, failSink(error: Error) { sinkError = error + }, + unresolve(worktreeId: string) { + unresolved.add(worktreeId) } } } @@ -86,7 +89,7 @@ describe('TerminalTopologyPublisher', () => { const session = sessionWith([WT, WT_B]) const publisher = new TerminalTopologyPublisher( () => - new Map([ + new Map([ [WT, { hostId: 'local', session }], [WT_B, { hostId: 'local', session }] ]), @@ -102,6 +105,44 @@ describe('TerminalTopologyPublisher', () => { ]) }) + it("reads a partition's sleeping records once per reconcile, however many worktrees it holds", () => { + const worktreeIds = Array.from({ length: 50 }, (_, index) => `repo-1::/tmp/wt-${index}`) + const session = sessionWith(worktreeIds) + let enumerations = 0 + const records: Record = {} + for (const worktreeId of worktreeIds) { + const paneKey = `tab-${worktreeId}:${TEST_LEAF_1}` + records[paneKey] = { + paneKey, + worktreeId, + agent: 'claude', + providerSession: { key: 'session_id', id: worktreeId }, + prompt: 'hi', + state: 'done', + capturedAt: 1, + updatedAt: 2 + } + } + session.sleepingAgentSessionsByPaneKey = new Proxy(records, { + ownKeys: (target) => { + enumerations += 1 + return Reflect.ownKeys(target) + } + }) + const pushes: TerminalTopologySlice[] = [] + const publisher = new TerminalTopologyPublisher( + () => new Map(worktreeIds.map((worktreeId) => [worktreeId, { hostId: 'local', session }])), + (slice) => pushes.push(slice) + ) + + publisher.markDirty() + publisher.flush() + + expect(enumerations).toBe(1) + expect(pushes).toHaveLength(worktreeIds.length) + expect(Object.keys(pushes[7]!.sleeping)).toEqual([`tab-${worktreeIds[7]}:${TEST_LEAF_1}`]) + }) + it('pushes only the worktree whose topology changed, once per burst', async () => { const h = harness(sessionWith([WT, WT_B])) const next = structuredClone(h.session) @@ -120,10 +161,10 @@ describe('TerminalTopologyPublisher', () => { expect(h.pushes[0]!.layouts[`tab-${WT}`]!.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'pty-2' }) }) - it('sees an in-place edit and ignores an equal rewrite or a presentation-only change', async () => { + it('sees an in-place edit and ignores an equal rewrite or a live title change', async () => { const h = harness(sessionWith([WT])) h.replace({ ...structuredClone(h.session) }) - h.session.tabsByWorktree[WT]![0]!.customTitle = 'renamed' + h.session.tabsByWorktree[WT]![0]!.title = 'vim' h.publisher.markDirty() await Promise.resolve() expect(h.pushes).toEqual([]) @@ -151,6 +192,7 @@ describe('TerminalTopologyPublisher', () => { publishSeq: 3, revision: 0, tabs: [], + presentation: {}, layouts: {}, sleeping: {} } @@ -169,16 +211,48 @@ describe('TerminalTopologyPublisher', () => { expect(h.publisher.settle()).toBe(seq) }) + it('names no push for a worktree it publishes no slice for', () => { + const h = harness(sessionWith([WT])) + + expect(h.publisher.settle('repo::/hosted-elsewhere')).toBeUndefined() + }) + + it('names the empty slice for a withdrawn worktree, not a later push for another', () => { + const h = harness(sessionWith([WT, WT_B])) + const next = sessionWith([WT_B]) + next.tabsByWorktree[WT_B]![0]!.ptyId = 'pty-b' + h.replace(next) + h.publisher.markDirty() + + const seq = h.publisher.settle(WT) + + expect(h.pushes.map(({ worktreeId, publishSeq }) => [worktreeId, publishSeq])).toEqual([ + [WT, seq], + [WT_B, h.publisher.settle()] + ]) + }) + + it('names the last slice for an unresolved worktree, whose writes are refused', () => { + const h = harness(sessionWith([WT])) + const seq = h.publisher.settle(WT) + h.unresolve(WT) + h.publisher.markDirty() + + expect(h.publisher.settle(WT)).toBe(seq) + expect(h.pushes).toEqual([]) + }) + it('keeps publishSeq monotonic across pushes', async () => { const h = harness(sessionWith([WT])) - const seqs: number[] = [h.publisher.settle(WT)] + const settle = (): number => h.publisher.settle(WT) ?? Number.NaN + const seqs = [settle()] for (const ptyId of ['a', 'b', 'c']) { const next = structuredClone(h.session) next.tabsByWorktree[WT]![0]!.ptyId = ptyId h.replace(next) h.publisher.markDirty() await Promise.resolve() - seqs.push(h.publisher.settle(WT)) + seqs.push(settle()) } expect(seqs).toEqual([...seqs].sort((a, b) => a - b)) @@ -201,4 +275,35 @@ describe('TerminalTopologyPublisher', () => { expect(h.readOwners).toHaveBeenCalledTimes(3) expect(warn).toHaveBeenCalledTimes(1) }) + + it('keeps an unresolved worktree on its last slice instead of sending it empty', async () => { + const h = harness(sessionWith([WT, WT_B])) + const before = h.publisher.snapshot() + h.unresolve(WT) + const next = structuredClone(h.session) + next.tabsByWorktree[WT] = [] + next.tabsByWorktree[WT_B]![0]!.ptyId = 'pty-b' + h.replace(next) + + h.publisher.markDirty() + await Promise.resolve() + + expect(h.pushes.map((slice) => slice.worktreeId)).toEqual([WT_B]) + expect(h.publisher.snapshot().find((slice) => slice.worktreeId === WT)).toEqual( + before.find((slice) => slice.worktreeId === WT) + ) + }) + + it('pulls the slices it pushed, including ones the window missed', async () => { + const h = harness(sessionWith([WT])) + h.replace(sessionWith([WT, WT_B])) + h.publisher.markDirty() + await Promise.resolve() + + expect(h.publisher.snapshot()).toEqual([ + expect.objectContaining({ worktreeId: WT, publishSeq: 1 }), + ...h.pushes + ]) + expect(h.pushes).toHaveLength(1) + }) }) diff --git a/src/main/runtime/terminal-topology-publisher.ts b/src/main/runtime/terminal-topology-publisher.ts index be712d295c17..b3d1aae38a98 100644 --- a/src/main/runtime/terminal-topology-publisher.ts +++ b/src/main/runtime/terminal-topology-publisher.ts @@ -1,15 +1,17 @@ import { isDeepStrictEqual } from 'node:util' -import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' +import type { TerminalSessionPartition } from '../persistence/terminal-topology/terminal-topology-membership' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { emptyTerminalTopologySlice, + groupSleepingRecordsByWorktree, projectTerminalTopologySlice, + type SleepingRecordsByWorktree, type UnsequencedTerminalTopologySlice } from './terminal-topology-projection' -/** A persisted worktree and the host partition that owns its rows. */ -export type WorkspaceSessionOwner = { hostId: ExecutionHostId; session: WorkspaceSessionState } +/** Each worktree's owning partition; null when it can't be resolved now (unverifiable, not absent). */ +export type TerminalTopologyOwners = Map type PublishedSlice = { publishSeq: number; snapshot: UnsequencedTerminalTopologySlice } @@ -20,13 +22,14 @@ export type TerminalTopologySink = (slice: TerminalTopologySlice) => void * Compares by value because some persistence writers edit session objects in place. */ export class TerminalTopologyPublisher { + /** A withdrawn worktree keeps its empty slice, so a reply can still name that push. */ private readonly published = new Map() private lastSeq = 0 private dirty = false private failureLogged = false constructor( - private readonly readOwners: () => Map, + private readonly readOwners: () => TerminalTopologyOwners, private readonly sink: TerminalTopologySink ) {} @@ -55,39 +58,53 @@ export class TerminalTopologyPublisher { } } - /** A publishSeq whose push includes every write made before this call. */ - settle(worktreeId?: string): number { + /** + * A publishSeq whose push includes every write made before this call; none for a worktree main + * publishes no slice for, since no push will ever carry it. An unresolved owner's writes are + * refused, so its last slice stands. + */ + settle(worktreeId?: string): number | undefined { this.flush() - return (worktreeId ? this.published.get(worktreeId)?.publishSeq : undefined) ?? this.lastSeq + return worktreeId === undefined ? this.lastSeq : this.published.get(worktreeId)?.publishSeq + } + + /** Every current slice, for a window that loaded after pushes it never saw. */ + snapshot(): TerminalTopologySlice[] { + // Owners also follow the repo catalog, which changes without a session write. + this.dirty = true + this.flush() + return [...this.published.values()].map(sequenced) } private reconcile(): void { const owners = this.readOwners() - const changed: UnsequencedTerminalTopologySlice[] = [] + const sleepingBySession = new Map() + const next: UnsequencedTerminalTopologySlice[] = [] for (const [worktreeId, owner] of owners) { - const next = projectTerminalTopologySlice(owner.session, owner.hostId, worktreeId) - if (!isDeepStrictEqual(this.published.get(worktreeId)?.snapshot, next)) { - changed.push(structuredClone(next)) + // The last slice stands until the owner resolves. + if (owner) { + const { session, hostId } = owner + const sleeping = sleepingBySession.get(session) ?? groupSleepingRecordsByWorktree(session) + sleepingBySession.set(session, sleeping) + next.push(projectTerminalTopologySlice(session, hostId, worktreeId, sleeping)) } } - const removed: UnsequencedTerminalTopologySlice[] = [] + const withdrawn: UnsequencedTerminalTopologySlice[] = [] for (const [worktreeId, { snapshot }] of this.published) { if (!owners.has(worktreeId)) { - removed.push(emptyTerminalTopologySlice(snapshot.hostId, worktreeId, snapshot.revision)) + withdrawn.push(emptyTerminalTopologySlice(snapshot.hostId, worktreeId, snapshot.revision)) } } - for (const snapshot of removed) { - this.published.delete(snapshot.worktreeId) - this.send({ publishSeq: ++this.lastSeq, snapshot }) - } - for (const snapshot of changed) { - const entry = { publishSeq: ++this.lastSeq, snapshot } - this.published.set(snapshot.worktreeId, entry) - this.send(entry) + for (const snapshot of [...withdrawn, ...next]) { + if (!isDeepStrictEqual(this.published.get(snapshot.worktreeId)?.snapshot, snapshot)) { + const entry = { publishSeq: ++this.lastSeq, snapshot: structuredClone(snapshot) } + this.published.set(snapshot.worktreeId, entry) + this.sink(sequenced(entry)) + } } } +} - private send({ publishSeq, snapshot }: PublishedSlice): void { - this.sink({ ...snapshot, publishSeq }) - } +function sequenced({ publishSeq, snapshot }: PublishedSlice): TerminalTopologySlice { + return { ...snapshot, publishSeq } } diff --git a/src/main/runtime/terminal-topology-runtime-publishing.test.ts b/src/main/runtime/terminal-topology-runtime-publishing.test.ts new file mode 100644 index 000000000000..3f58bc9881e2 --- /dev/null +++ b/src/main/runtime/terminal-topology-runtime-publishing.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../persistence-session-fixtures' +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimeNotifier } from './runtime-notifier-contract' + +const WT = 'repo1::/tmp/wt' +const TAB = 'tab-1' +const cleanups: (() => void)[] = [] + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } +}) + +function sessionWithPty(ptyId: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WT]: [ + { + id: TAB, + worktreeId: WT, + ptyId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB]: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: ptyId } + } + } + } +} + +function notifierWith( + terminalTopologyChanged?: RuntimeNotifier['terminalTopologyChanged'] +): RuntimeNotifier { + const ignore = (): void => {} + return { + worktreesChanged: ignore, + reposChanged: ignore, + activateWorktree: ignore, + createTerminal: ignore, + splitTerminal: ignore, + renameTerminal: ignore, + focusTerminal: ignore, + closeTerminal: ignore, + sleepWorktree: ignore, + terminalFitOverrideChanged: ignore, + terminalDriverChanged: ignore, + ...(terminalTopologyChanged ? { terminalTopologyChanged } : {}) + } +} + +/** Store writes reach the runtime only through `onWorkspaceSessionWritten`, as with the real Store. */ +function setup() { + const listeners = new Set<() => void>() + let session = sessionWithPty('pty-1') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: publishing reads only repos, folder workspaces and workspace sessions. + const store = { + getRepos: () => [{ id: 'repo1' }], + getRepo: () => ({ id: 'repo1' }), + getFolderWorkspaces: () => [], + getWorkspaceSessionHostIds: () => ['local'], + getWorkspaceSession: () => session, + onWorkspaceSessionWritten: (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } as never + const runtime = new OrcaRuntimeService(store) + const pushes: TerminalTopologySlice[] = [] + runtime.setNotifier(notifierWith((slice) => pushes.push(slice))) + cleanups.push(() => runtime.setNotifier(null)) + // The first pull publishes the starting topology; tests observe what follows. + const initial = runtime.getTerminalTopologySlices() + pushes.length = 0 + const write = (next: WorkspaceSessionState): void => { + session = next + for (const listener of listeners) { + listener() + } + } + return { write, runtime, pushes, initial } +} + +describe('main publishes terminal topology to the window', () => { + it('pushes a committed change, and the pull and the reply name the same slice', async () => { + const { write, runtime, pushes, initial } = setup() + expect(initial).toEqual([expect.objectContaining({ hostId: 'local', worktreeId: WT })]) + + write(sessionWithPty('pty-2')) + await Promise.resolve() + + expect(pushes).toHaveLength(1) + expect(pushes[0]!.publishSeq).toBeGreaterThan(initial[0]!.publishSeq) + expect(pushes[0]!.layouts[TAB]!.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'pty-2' }) + expect(runtime.getTerminalTopologySlices()).toEqual(pushes) + expect(runtime.settleTerminalTopology(WT)).toBe(pushes[0]!.publishSeq) + }) + + it('pushes nothing for an identical write or a live title change', async () => { + const { write, pushes } = setup() + + write(sessionWithPty('pty-1')) + const retitled = sessionWithPty('pty-1') + retitled.tabsByWorktree[WT]![0]!.title = 'vim' + write(retitled) + await Promise.resolve() + + expect(pushes).toEqual([]) + }) + + it('a reply settles a write made in the same task before its push goes out', () => { + const { write, runtime, pushes } = setup() + + write(sessionWithPty('pty-2')) + const seq = runtime.settleTerminalTopology(WT) + + expect(pushes.map((slice) => slice.publishSeq)).toEqual([seq]) + }) + + it('covers a push the window missed while reloading with the pull', async () => { + const { write, runtime, pushes } = setup() + runtime.setNotifier(notifierWith()) + + const split = sessionWithPty('pty-1') + split.terminalLayoutsByTabId[TAB] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + }, + activeLeafId: TEST_LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1', [TEST_LEAF_2]: 'pty-split' } + } + write(split) + await Promise.resolve() + runtime.setNotifier(notifierWith((slice) => pushes.push(slice))) + + expect(pushes).toEqual([]) + const [pulled] = runtime.getTerminalTopologySlices() + expect(pulled!.layouts[TAB]!.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'pty-1', + [TEST_LEAF_2]: 'pty-split' + }) + }) +}) diff --git a/src/main/runtime/workspace-session-membership-scaling.test.ts b/src/main/runtime/workspace-session-membership-scaling.test.ts deleted file mode 100644 index 802245461b77..000000000000 --- a/src/main/runtime/workspace-session-membership-scaling.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { TabGroup } from '../../shared/tab-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { rebaseWorkspaceSessionTerminalMembership } from './workspace-session-terminal-membership-authority' - -it('rebases a large group without rescanning tab order for every recent tab', () => { - const ids = Array.from({ length: 1000 }, (_, index) => `tab-${index}`) - const session: WorkspaceSessionState = { - activeRepoId: 'repo', - activeWorktreeId: 'repo::/workspace', - activeTabId: null, - tabsByWorktree: { - 'repo::/workspace': ids.map((id, index) => ({ - id, - worktreeId: 'repo::/workspace', - ptyId: null, - title: id, - customTitle: null, - color: null, - sortOrder: index, - createdAt: 0 - })) - }, - terminalLayoutsByTabId: {}, - terminalTopologyRevisionByRepoId: { repo: 1 }, - tabGroups: { - 'repo::/workspace': [ - { - id: 'group', - worktreeId: 'repo::/workspace', - activeTabId: 'missing', - tabOrder: [...ids, 'missing'], - recentTabIds: [...ids, 'missing'] - } - ] - } - } - const includes = vi.spyOn(Array.prototype, 'includes') - let result: WorkspaceSessionState - let probes: number - try { - result = rebaseWorkspaceSessionTerminalMembership(session, session) - probes = includes.mock.calls.length - } finally { - includes.mockRestore() - } - expect(probes).toBeLessThan(10) - expect(result.tabGroups?.['repo::/workspace'][0]).toMatchObject({ - tabOrder: ids, - recentTabIds: ids, - activeTabId: ids[0] - }) -}) - -// Why: the rebased session is the host-authoritative one, so a tab id the host no -// longer has must not survive into it. `activeTabId` already failed closed; the -// filtered `recentTabIds` used to be dropped when it emptied, letting the `...group` -// spread put the unfiltered array back. -it('drops tab ids the host no longer has from group membership, failing closed', () => { - const buildSession = ( - activeTabId: string | null, - recentTabIds: string[] | undefined - ): WorkspaceSessionState => - ({ - activeRepoId: 'repo', - activeWorktreeId: 'repo::/workspace', - activeTabId: null, - tabsByWorktree: { - 'repo::/workspace': ['kept-a', 'kept-b'].map((id, index) => ({ - id, - worktreeId: 'repo::/workspace', - ptyId: null, - title: id, - customTitle: null, - color: null, - sortOrder: index, - createdAt: 0 - })) - }, - terminalLayoutsByTabId: {}, - terminalTopologyRevisionByRepoId: { repo: 1 }, - tabGroups: { - 'repo::/workspace': [ - { - id: 'group', - worktreeId: 'repo::/workspace', - activeTabId, - tabOrder: ['kept-a', 'closed-on-host', 'kept-b'], - ...(recentTabIds ? { recentTabIds } : {}) - } - ] - } - }) as WorkspaceSessionState - - const rebase = ( - activeTabId: string | null, - recentTabIds: string[] | undefined - ): TabGroup | undefined => { - const session = buildSession(activeTabId, recentTabIds) - return rebaseWorkspaceSessionTerminalMembership(session, session).tabGroups?.[ - 'repo::/workspace' - ][0] - } - - // Every recent id is stale: the array must empty, not revert to the stale one. - expect(rebase('kept-b', ['closed-on-host'])).toMatchObject({ - tabOrder: ['kept-a', 'kept-b'], - activeTabId: 'kept-b', - recentTabIds: [] - }) - // Mixed: only the host-known ids survive, in order. - expect(rebase('kept-a', ['closed-on-host', 'kept-b', 'closed-on-host', 'kept-a'])).toMatchObject({ - recentTabIds: ['kept-b', 'kept-a'] - }) - // A stale active tab falls back to the first surviving tab, never to the stale id. - expect(rebase('closed-on-host', ['kept-a'])).toMatchObject({ - activeTabId: 'kept-a', - recentTabIds: ['kept-a'] - }) - expect(rebase(null, undefined)).toMatchObject({ activeTabId: 'kept-a' }) - // A group that never carried recentTabIds must not gain the key. - expect(rebase('kept-a', undefined)).not.toHaveProperty('recentTabIds') -}) diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts deleted file mode 100644 index 4c2ea719da52..000000000000 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { Tab, TabGroup } from '../../shared/tab-types' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode -} from '../../shared/terminal-tab-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' -import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement' - -function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set): void { - if (!node) { - return - } - if (node.type === 'leaf') { - ids.add(node.leafId) - return - } - collectLeafIds(node.first, ids) - collectLeafIds(node.second, ids) -} - -function layoutHasSameMembership( - candidate: TerminalLayoutSnapshot, - current: TerminalLayoutSnapshot -): boolean { - const candidateIds = new Set() - const currentIds = new Set() - collectLeafIds(candidate.root, candidateIds) - collectLeafIds(current.root, currentIds) - return ( - candidateIds.size === currentIds.size && - [...candidateIds].every((leafId) => currentIds.has(leafId)) - ) -} - -function rebaseLayout( - candidate: TerminalLayoutSnapshot | undefined, - current: TerminalLayoutSnapshot | undefined -): TerminalLayoutSnapshot | undefined { - if (!current) { - return undefined - } - if (!candidate || !layoutHasSameMembership(candidate, current)) { - return current - } - return { - ...candidate, - // Why: renderer layout metadata may move, but only host-persisted live bindings may name PTYs. - ptyIdsByLeafId: { - ...candidate.ptyIdsByLeafId, - ...current.ptyIdsByLeafId - } - } -} - -function terminalUnifiedTabMatches(tab: Tab, terminalTabIds: ReadonlySet): boolean { - return ( - tab.contentType === 'terminal' && - (terminalTabIds.has(tab.id) || terminalTabIds.has(tab.entityId)) - ) -} - -function rebaseUnifiedTabs( - candidate: readonly Tab[], - current: readonly Tab[], - terminalTabIds: ReadonlySet -): Tab[] { - const result = candidate.filter( - (tab) => tab.contentType !== 'terminal' || terminalUnifiedTabMatches(tab, terminalTabIds) - ) - const representedTerminalIds = new Set( - result.flatMap((tab) => (tab.contentType === 'terminal' ? [tab.id, tab.entityId] : [])) - ) - for (const tab of current) { - if ( - terminalUnifiedTabMatches(tab, terminalTabIds) && - !representedTerminalIds.has(tab.id) && - !representedTerminalIds.has(tab.entityId) - ) { - result.push(tab) - } - } - return result -} - -function rebaseTabGroups( - groups: readonly TabGroup[], - validTabIds: ReadonlySet -): TabGroup[] { - return groups.flatMap((group) => { - const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) - if (tabOrder.length === 0) { - return [] - } - const tabIds = new Set(tabOrder) - const activeTabId = - group.activeTabId && tabIds.has(group.activeTabId) ? group.activeTabId : (tabOrder[0] ?? null) - const recentTabIds = group.recentTabIds?.filter((tabId) => tabIds.has(tabId)) - return [ - { - ...group, - tabOrder, - activeTabId, - // Why assigned even when it filters to empty: omitting the key lets `...group` - // re-introduce the unfiltered array, persisting ids for tabs the host dropped. - ...(group.recentTabIds ? { recentTabIds: recentTabIds ?? [] } : {}) - } - ] - }) -} - -function rebaseIncarnationBindings( - session: WorkspaceSessionState, - prior: WorkspaceSessionState -): Record | undefined { - const terminalTabIds = new Set( - Object.values(session.tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) - ) - const allowedPaneKeys = new Set() - for (const tabId of terminalTabIds) { - const layout = session.terminalLayoutsByTabId[tabId] - if (!layout) { - continue - } - const leafIds = new Set() - collectLeafIds(layout.root, leafIds) - for (const leafId of leafIds) { - allowedPaneKeys.add(`${tabId}:${leafId}`) - } - } - const merged = { - ...session.terminalPtyIncarnationsByPaneKey, - ...prior.terminalPtyIncarnationsByPaneKey - } - const retained = Object.fromEntries( - Object.entries(merged).filter(([paneKey]) => { - const separator = paneKey.lastIndexOf(':') - if (separator < 1) { - return false - } - const tabId = paneKey.slice(0, separator) - return session.terminalLayoutsByTabId[tabId] - ? allowedPaneKeys.has(paneKey) - : terminalTabIds.has(tabId) - }) - ) - return Object.keys(retained).length > 0 ? retained : undefined -} - -export function rebaseWorkspaceSessionTerminalMembership( - incoming: WorkspaceSessionState, - prior: WorkspaceSessionState | undefined -): WorkspaceSessionState { - if (!prior?.terminalTopologyRevisionByRepoId) { - return incoming - } - const terminalTopologyRevisionByRepoId = { ...incoming.terminalTopologyRevisionByRepoId } - for (const [repoId, revision] of Object.entries(prior.terminalTopologyRevisionByRepoId)) { - terminalTopologyRevisionByRepoId[repoId] = Math.max( - revision, - terminalTopologyRevisionByRepoId[repoId] ?? 0 - ) - } - const tabsByWorktree = { ...incoming.tabsByWorktree } - const incomingTerminalLayoutsByTabId = incoming.terminalLayoutsByTabId ?? {} - const priorTerminalLayoutsByTabId = prior.terminalLayoutsByTabId ?? {} - const terminalLayoutsByTabId = { ...incomingTerminalLayoutsByTabId } - const unifiedTabs = { ...incoming.unifiedTabs } - const tabGroups = { ...incoming.tabGroups } - const tabGroupLayouts = { ...incoming.tabGroupLayouts } - const activeTabIdByWorktree = { ...incoming.activeTabIdByWorktree } - let includeUnifiedTabs = incoming.unifiedTabs !== undefined - let includeTabGroups = incoming.tabGroups !== undefined - let includeTabGroupLayouts = incoming.tabGroupLayouts !== undefined - let rebasedMembership = false - const worktreeIds = new Set([ - ...Object.keys(prior.tabsByWorktree), - ...Object.keys(incoming.tabsByWorktree) - ]) - for (const worktreeId of worktreeIds) { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const revision = terminalTopologyRevisionByRepoId[repoId] ?? 0 - const priorRevision = prior.terminalTopologyRevisionByRepoId[repoId] ?? 0 - const incomingRevision = incoming.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - if (revision <= 0 || incomingRevision > priorRevision) { - continue - } - rebasedMembership = true - const currentTabs = prior.tabsByWorktree[worktreeId] ?? [] - const candidateTabsById = new Map( - (incoming.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) - ) - const terminalTabIds = new Set(currentTabs.map((tab) => tab.id)) - const tabs = currentTabs.map((current) => { - const candidate = candidateTabsById.get(current.id) - return candidate ? { ...candidate, ptyId: current.ptyId } : current - }) - for (const candidate of incoming.tabsByWorktree[worktreeId] ?? []) { - if (!terminalTabIds.has(candidate.id)) { - delete terminalLayoutsByTabId[candidate.id] - } - } - for (const tabId of terminalTabIds) { - const layout = rebaseLayout( - incomingTerminalLayoutsByTabId[tabId], - priorTerminalLayoutsByTabId[tabId] - ) - if (layout) { - terminalLayoutsByTabId[tabId] = layout - } else { - delete terminalLayoutsByTabId[tabId] - } - } - const rebasedUnifiedTabs = rebaseUnifiedTabs( - incoming.unifiedTabs?.[worktreeId] ?? [], - prior.unifiedTabs?.[worktreeId] ?? [], - terminalTabIds - ) - if (includeUnifiedTabs || rebasedUnifiedTabs.length > 0) { - unifiedTabs[worktreeId] = rebasedUnifiedTabs - includeUnifiedTabs = true - } - const validTabIds = new Set([...terminalTabIds, ...rebasedUnifiedTabs.map((tab) => tab.id)]) - const rebasedGroups = rebaseTabGroups( - incoming.tabGroups?.[worktreeId] ?? prior.tabGroups?.[worktreeId] ?? [], - validTabIds - ) - if (includeTabGroups || rebasedGroups.length > 0) { - tabGroups[worktreeId] = rebasedGroups - includeTabGroups = true - } - const rebasedGroupLayout = pruneTabGroupLayoutAfterRetirement( - incoming.tabGroupLayouts?.[worktreeId] ?? prior.tabGroupLayouts?.[worktreeId], - new Set(rebasedGroups.map((group) => group.id)) - ) - if (rebasedGroupLayout) { - tabGroupLayouts[worktreeId] = rebasedGroupLayout - includeTabGroupLayouts = true - } else { - delete tabGroupLayouts[worktreeId] - } - if (!validTabIds.has(activeTabIdByWorktree[worktreeId] ?? '')) { - activeTabIdByWorktree[worktreeId] = - (prior.activeTabIdByWorktree?.[worktreeId] && - validTabIds.has(prior.activeTabIdByWorktree[worktreeId] ?? '') - ? prior.activeTabIdByWorktree[worktreeId] - : (rebasedGroups[0]?.activeTabId ?? tabs[0]?.id)) ?? null - } - tabsByWorktree[worktreeId] = tabs - } - const next: WorkspaceSessionState = { - ...incoming, - terminalTopologyRevisionByRepoId, - ...(rebasedMembership - ? { - tabsByWorktree, - terminalLayoutsByTabId, - activeTabIdByWorktree, - ...(includeUnifiedTabs ? { unifiedTabs } : {}), - ...(includeTabGroups ? { tabGroups } : {}), - ...(includeTabGroupLayouts ? { tabGroupLayouts } : {}) - } - : {}) - } - return rebasedMembership - ? { ...next, terminalPtyIncarnationsByPaneKey: rebaseIncarnationBindings(next, prior) } - : next -} diff --git a/src/main/ssh-expired-lease-pane-readoption.test.ts b/src/main/ssh-expired-lease-pane-readoption.test.ts index 11bf5fe2083d..35ca9d1f3d60 100644 --- a/src/main/ssh-expired-lease-pane-readoption.test.ts +++ b/src/main/ssh-expired-lease-pane-readoption.test.ts @@ -155,6 +155,12 @@ describe('recovery through createTerminal reattaches before it respawns', () => // A null adoption is exactly what routes createTerminal to a fresh shell, so a pane whose // shell genuinely died still gets a working terminal. await expect(adoptStablePane(undefined, store, ADOPT_ARGS)).resolves.toBeNull() - expect(resolvePersistedStablePaneOwner(store, PANE_KEY, WORKTREE, TARGET)).toBeNull() + // The pane and its row keep the binding for the fresh shell's bind to swap. + expect(resolvePersistedStablePaneOwner(store, PANE_KEY, WORKTREE, TARGET)).toMatchObject({ + ptyId: APP_PTY_ID + }) + expect(store.getWorkspaceSession(`ssh:${TARGET}`).tabsByWorktree[WORKTREE]?.[0]?.ptyId).toBe( + APP_PTY_ID + ) }) }) diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index d4c795817a35..e713ddd02222 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -281,9 +281,8 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () }) // A close raises the repo's topology revision, and that is what the fence reads. Pinned as - // behavior because `terminalSurfaceTombstonesByPaneKey` — the older per-surface fence — is - // consumed and cleared by `sanitizeWorkspaceSessionTerminalRetirements` on every session write, - // so it is never present by the time a binding write could consult it. + // behavior because `terminalSurfaceTombstonesByPaneKey` — the older per-surface fence — is no + // longer written, so a binding write cannot count on it. it('treats a raised topology revision as the authority that makes absence a close', async () => { const store = await createStore() store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' })) diff --git a/src/main/terminal-topology-main-writer-pushes.test.ts b/src/main/terminal-topology-main-writer-pushes.test.ts new file mode 100644 index 000000000000..5dbbb6ad9aac --- /dev/null +++ b/src/main/terminal-topology-main-writer-pushes.test.ts @@ -0,0 +1,208 @@ +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 { getDefaultWorkspaceSession } from '../shared/constants' +import type { ExecutionHostId } from '../shared/execution-host' +import type { TerminalTopologySlice } from '../shared/terminal-topology-slice' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import type { Store } from './persistence/loading-store/store' +import { makeTerminalTab, TEST_LEAF_1 } from './persistence-session-fixtures' +import { closeTestStores, createStore, makeRepo, testState } from './persistence-test-harness' +import { RuntimeWorkspaceSessionController } from './runtime/runtime-workspace-session-controller' +import { TerminalTopologyPublisher } from './runtime/terminal-topology-publisher' + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +/** One worktree holding one bound single-pane tab. */ +function sessionWithTab(worktreeId: string, ptyId: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab', worktreeId, ptyId })] }, + terminalLayoutsByTabId: { + tab: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: ptyId } + } + } + } +} + +/** Main's publisher wired to a real store the way the runtime wires it; records what follows the first pull. */ +function watchPushes(store: Store) { + const controller = new RuntimeWorkspaceSessionController({ + getStore: () => store, + resolveFolderConnectionId: () => null, + hasRuntimeOwnedPtyCandidate: () => false + }) + const pushes: TerminalTopologySlice[] = [] + const publisher = new TerminalTopologyPublisher( + () => controller.getTerminalTopologyOwners(), + (slice) => pushes.push(slice) + ) + store.onWorkspaceSessionWritten(() => publisher.markDirty()) + const initial = publisher.snapshot() + pushes.length = 0 + return { + initial, + pushesAfter: (write: () => unknown): TerminalTopologySlice[] => { + write() + publisher.flush() + return pushes + } + } +} + +function withdrawn(hostId: ExecutionHostId, worktreeId: string) { + return expect.objectContaining({ hostId, worktreeId, tabs: [], layouts: {} }) +} + +describe('a window showing a worktree learns of main-side writes that remove or rekey it', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-topology-writer-pushes-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('withdraws a folder workspace deleted directly or with its project group', () => { + for (const viaGroup of [false, true]) { + const store = createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + const folder = store.createFolderWorkspace({ + projectGroupId: group.id, + folderPath: '/workspace/platform' + }) + const key = folderWorkspaceKey(folder.id) + store.setWorkspaceSession(sessionWithTab(key, 'pty-folder')) + const { initial, pushesAfter } = watchPushes(store) + expect(initial.map((slice) => slice.worktreeId)).toEqual([key]) + + const pushes = pushesAfter(() => + viaGroup ? store.deleteProjectGroup(group.id) : store.removeFolderWorkspace(folder.id) + ) + + expect(pushes).toEqual([withdrawn('local', key)]) + } + }) + + it('withdraws the worktrees of a removed repo', () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + store.setWorkspaceSession(sessionWithTab('r1::/repo', 'pty-repo')) + const { initial, pushesAfter } = watchPushes(store) + expect(initial).toHaveLength(1) + + expect(pushesAfter(() => store.removeProjectForHost('r1', 'local'))).toEqual([ + withdrawn('local', 'r1::/repo') + ]) + }) + + it("withdraws one host's worktree when a shared repo id is removed from that host only", () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/remote/repo', + connectionId: 'ssh-a', + executionHostId: 'ssh:ssh-a' + }) + ) + const worktreeId = 'shared::/local/repo/wt' + store.setWorktreeMeta(worktreeId, { displayName: 'local-wt', hostId: 'local' }) + store.setWorkspaceSession(sessionWithTab(worktreeId, 'pty-local')) + const { initial, pushesAfter } = watchPushes(store) + expect(initial.map((slice) => slice.worktreeId)).toEqual([worktreeId]) + + // The id survives on the SSH host, so only the per-host prune removes these rows. + const pushes = pushesAfter(() => store.removeProjectForHost('shared', 'local')) + + expect(pushes).toEqual([withdrawn('local', worktreeId)]) + }) + + it('withdraws the old identity and publishes the tab under the new one on a rename', () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'repo-1', path: '/workspace' })) + const oldId = 'repo-1::/workspace/feature' + const newId = 'repo-1::/workspace/renamed' + store.setWorkspaceSession(sessionWithTab(oldId, 'pty-feature')) + const { pushesAfter } = watchPushes(store) + + const pushes = pushesAfter(() => store.migrateWorktreeIdentity(oldId, newId)) + + expect(pushes).toEqual([ + withdrawn('local', oldId), + expect.objectContaining({ + hostId: 'local', + worktreeId: newId, + tabs: [expect.objectContaining({ id: 'tab', ptyId: 'pty-feature' })] + }) + ]) + }) + + it('republishes an SSH worktree under the re-added target, with its bindings re-pointed', () => { + const store = createStore() + store.addRepo( + makeRepo({ + id: 'remote', + path: '/remote/repo', + connectionId: 'old-target', + executionHostId: 'ssh:old-target' + }) + ) + const worktreeId = 'remote::/remote/repo' + store.setWorkspaceSession(sessionWithTab(worktreeId, 'ssh:old-target@@pty-1'), 'ssh:old-target') + const { initial, pushesAfter } = watchPushes(store) + expect(initial.map((slice) => slice.hostId)).toEqual(['ssh:old-target']) + + const pushes = pushesAfter(() => store.reassignSshTargetId('old-target', 'new-target')) + + expect(pushes).toHaveLength(1) + expect(pushes[0]).toMatchObject({ hostId: 'ssh:new-target', worktreeId }) + expect(pushes[0]!.tabs[0]!.ptyId).toBe('ssh:new-target@@pty-1') + expect(pushes[0]!.layouts.tab!.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'ssh:new-target@@pty-1' + }) + }) + + it('pushes the unbound pane when main terminates its SSH lease', () => { + const store = createStore() + const worktreeId = 'wt1' + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + worktreeId, + tabId: 'tab', + leafId: TEST_LEAF_1, + state: 'attached' + }) + store.setWorkspaceSession(sessionWithTab(worktreeId, 'ssh:ssh-1@@remote-pty')) + const { pushesAfter } = watchPushes(store) + + // The cleanup edits the session in place; the push must still see it. + const pushes = pushesAfter(() => + store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + ) + + expect(pushes).toHaveLength(1) + expect(pushes[0]!.tabs[0]!.ptyId).toBeNull() + expect(pushes[0]!.layouts.tab!.ptyIdsByLeafId).toEqual({}) + }) +}) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 95abe7c35158..6fc5a1f03f9b 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -221,7 +221,9 @@ export function registerRuntimeWindowLifecycle( send('runtime:browserDriverChanged', { browserPageId, driver }), browserRemoteViewersChanged: (browserPageId, hasRemoteViewers) => send('runtime:browserRemoteViewersChanged', { browserPageId, hasRemoteViewers }), - clientHostedBrowserRowsChanged: (event) => send('runtime:clientHostedBrowserRowsChanged', event) + clientHostedBrowserRowsChanged: (event) => + send('runtime:clientHostedBrowserRowsChanged', event), + terminalTopologyChanged: (slice) => send('session:terminal-topology-changed', slice) }) registerRendererDocumentNavigation(mainWebContents, () => { rendererNotifications.onMainFrameReloadStarted() diff --git a/src/main/worktree-removal-session-partition-fencing.test.ts b/src/main/worktree-removal-session-partition-fencing.test.ts index 8a5f1dad8a45..ce58ac95f313 100644 --- a/src/main/worktree-removal-session-partition-fencing.test.ts +++ b/src/main/worktree-removal-session-partition-fencing.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import type { TerminalTab } from '../shared/terminal-tab-types' import { getDefaultWorkspaceSession } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' let hasCreatedStoreInCase = false @@ -131,14 +132,15 @@ describe('worktree removal across host session partitions', () => { expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() expect(store.getWorkspaceSession(ENV_A).terminalTopologyRevisionByRepoId?.['repo-gone']).toBe(1) - store.setWorkspaceSession( + setRendererSession( + store, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [STALE]: [makeTerminalTab({ id: 'stale-tab', worktreeId: STALE })] } }, ENV_A ) - expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toEqual([]) + expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() }) // Regression: runtime removal purged only the host partition, so tabs the renderer had parked in the @@ -215,7 +217,8 @@ describe('worktree removal across host session partitions', () => { expect(store.getWorkspaceSession(ENV_A).terminalTopologyRevisionByRepoId?.['repo-gone']).toBe(1) - store.setWorkspaceSession( + setRendererSession( + store, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [STALE]: [makeTerminalTab({ id: 'delayed-tab', worktreeId: STALE })] } @@ -223,7 +226,7 @@ describe('worktree removal across host session partitions', () => { ENV_A ) - expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toEqual([]) + expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() }) // The widened local purge must not fence the local blob when only siblings live there. diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index cbcf78a4abe2..68ec032dde7f 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -8,6 +8,7 @@ import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../shared/terminal-leaf-move' +import type { TerminalTopologyReply } from '../../shared/terminal-topology-slice' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -83,6 +84,8 @@ export type PtyApi = { agentResumeUnavailable?: true /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean + /** The topology push holding this spawn's binding. */ + publishSeq?: number }> write: (id: string, data: string, inputKind: TerminalInputKind) => void /** `requireWriteSettlement` waits for the provider's acknowledgment on any provider. */ @@ -154,7 +157,9 @@ export type PtyApi = { ids: string[] ) => Promise<{ id: string; authoritative: boolean | null }[]> hasPty: (id: string) => Promise - moveLeafToNewTab: (request: TerminalLeafMoveRequest) => Promise + moveLeafToNewTab: ( + request: TerminalLeafMoveRequest + ) => Promise getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 3b996d61ea23..9a2e4735a466 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -20,6 +20,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' import type { PreloadApi } from '../api-types' +import type { TerminalTopologyReply } from '../../shared/terminal-topology-slice' import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' export const ptySessionControlApi = { @@ -78,6 +79,8 @@ export const ptySessionControlApi = { agentResumeUnavailable?: true /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean + /** The topology push holding this spawn's binding. */ + publishSeq?: number }> => ipcRenderer.invoke('pty:spawn', opts), write: (id: string, data: string, inputKind: TerminalInputKind): void => { ipcRenderer.send('pty:write', { id, data, inputKind }) @@ -167,7 +170,9 @@ export const ptySessionControlApi = { ): Promise<{ id: string; authoritative: boolean | null }[]> => ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), - moveLeafToNewTab: (request: TerminalLeafMoveRequest): Promise => + moveLeafToNewTab: ( + request: TerminalLeafMoveRequest + ): Promise => ipcRenderer.invoke('pty:moveLeafToNewTab', request), getMainBufferSnapshot: ( id: string, diff --git a/src/preload/api/remote-workspace-bridge.ts b/src/preload/api/remote-workspace-bridge.ts index 2a7ca528d211..a5a7bab8ad0a 100644 --- a/src/preload/api/remote-workspace-bridge.ts +++ b/src/preload/api/remote-workspace-bridge.ts @@ -1,11 +1,13 @@ import { ipcRenderer } from 'electron' -import type { RemoteWorkspaceChangedEvent } from '../../shared/remote-workspace-types' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspacePushStatusEvent +} from '../../shared/remote-workspace-types' import type { PreloadApi } from '../api-types' export const remoteWorkspaceApi = { get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), - setForConnectedTargets: (args) => - ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), + importPeerTopology: (pull) => ipcRenderer.invoke('remoteWorkspace:importPeerTopology', pull), listEnabledConnectedTargets: () => ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), listConnectedClients: (args) => ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), @@ -15,5 +17,11 @@ export const remoteWorkspaceApi = { callback(data) ipcRenderer.on('remoteWorkspace:changed', listener) return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) + }, + onPushStatus: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspacePushStatusEvent) => + callback(data) + ipcRenderer.on('remoteWorkspace:pushStatus', listener) + return () => ipcRenderer.removeListener('remoteWorkspace:pushStatus', listener) } } satisfies PreloadApi['remoteWorkspace'] diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 537e15179ddd..948f3517d503 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' +import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' export const sessionApi = { // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. @@ -7,7 +8,21 @@ export const sessionApi = { listHostIds: () => ipcRenderer.invoke('session:list-host-ids'), set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + createTerminalSurface: (args) => ipcRenderer.invoke('session:terminal-create-surface', args), closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), + commitTerminalSleepingRecords: (changes) => + ipcRenderer.invoke('session:commit-terminal-sleeping-records', changes), + setTerminalLayout: (args) => ipcRenderer.invoke('session:terminal-set-layout', args), + bindTerminalLeaf: (args) => ipcRenderer.invoke('session:terminal-bind-leaf', args), + clearTerminalLaunchAgent: (args) => + ipcRenderer.invoke('session:terminal-clear-launch-agent', args), + getTerminalTopologySlices: () => ipcRenderer.invoke('session:get-terminal-topology-slices'), + onTerminalTopologyChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, slice: TerminalTopologySlice): void => + callback(slice) + ipcRenderer.on('session:terminal-topology-changed', listener) + return () => ipcRenderer.removeListener('session:terminal-topology-changed', listener) + }, flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index d621e5dc7db6..e3306493e17f 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -5,11 +5,29 @@ import type { } from '../../shared/workspace-session-state-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' +import type { + TerminalLayoutSetRequest, + TerminalLayoutSetResult +} from '../../shared/terminal-layout-set' +import type { + TerminalSurfaceCreateRequest, + TerminalSurfaceCreateResult +} from '../../shared/terminal-surface-create' +import type { + TerminalLeafBindRequest, + TerminalLeafBindResult +} from '../../shared/terminal-leaf-bind' +import type { + TerminalSleepingRecordChanges, + TerminalTopologyReply, + TerminalTopologySlice +} from '../../shared/terminal-topology-slice' import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport, + RemoteWorkspacePushStatusEvent } from '../../shared/remote-workspace-types' export type WorkspaceSessionApi = { @@ -20,12 +38,31 @@ export type WorkspaceSessionApi = { listHostIds: () => Promise set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + /** Commits a terminal tab or split pane this window created into main's membership, unbound. */ + createTerminalSurface: ( + args: TerminalSurfaceCreateRequest + ) => Promise /** Commits a terminal tab or split-pane close into main's membership. */ closeTerminalSurface: (args: { worktreeId: string target: TerminalSurfaceCloseTarget reason?: 'user' | 'cleanup' - }) => Promise + }) => Promise + /** Commits a user's same-pane geometry edit (dividers, directions, pane order) into main. */ + setTerminalLayout: ( + args: TerminalLayoutSetRequest + ) => Promise + /** Has main record a live PTY the window adopted onto a pane main holds. */ + bindTerminalLeaf: ( + args: TerminalLeafBindRequest + ) => Promise + /** Drops a tab's launched agent from main's row once the window sees the agent gone. */ + clearTerminalLaunchAgent: (args: { worktreeId: string; tabId: string }) => Promise + /** Sleeping-agent records this window wrote or dropped, committed beside their tabs in main. */ + commitTerminalSleepingRecords: (changes: TerminalSleepingRecordChanges) => Promise + /** Every worktree's current terminal topology; pull after subscribing to the pushes. */ + getTerminalTopologySlices: () => Promise + onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void @@ -44,17 +81,15 @@ export type WorkspaceSessionApi = { } remoteWorkspace: { get: (args: { targetId: string }) => Promise - setForConnectedTargets: (args: { - session?: WorkspaceSessionState - hydratedTargetIds?: string[] - expectedRevisionsByTargetId: Record - expectedHostObservationTokensByTargetId: Record - }) => Promise<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]> + /** Commits a pull's session writes in main and records what this desktop and the host agree on. */ + importPeerTopology: (pull: RemoteWorkspacePeerImport) => Promise listEnabledConnectedTargets: () => Promise listConnectedClients: (args?: { targetIds?: string[] }) => Promise<{ targetId: string; clients: RemoteWorkspaceConnectedClient[] }[]> clientId: () => Promise onChanged: (callback: (event: RemoteWorkspaceChangedEvent) => void) => () => void + /** Main's report of each export it ran. */ + onPushStatus: (callback: (event: RemoteWorkspacePushStatusEvent) => void) => () => void } } diff --git a/src/relay/relay-runtime-owned-process-shutdown.test.ts b/src/relay/relay-runtime-owned-process-shutdown.test.ts index 6cdef3ed1fce..97303d1897d9 100644 --- a/src/relay/relay-runtime-owned-process-shutdown.test.ts +++ b/src/relay/relay-runtime-owned-process-shutdown.test.ts @@ -13,6 +13,7 @@ function fixture() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shutdown paths only read the owners stubbed here. const runtime = Object.assign(Object.create(RelayRuntimeServices.prototype), { agentExecHandler: { dispose: agents }, + workspaceSessionHandler: { close: vi.fn() }, skillInstallHandler: { dispose: skill }, aiVaultService: { dispose: vault }, responseStreams: { disposeAllAndWait: responses }, diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index 1325e792d078..6b8a923410d6 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -33,6 +33,7 @@ export class RelayRuntimeServices { readonly gitHandler: GitHandler readonly skillInstallHandler: SkillInstallHandler readonly agentExecHandler: AgentExecHandler + private readonly workspaceSessionHandler: WorkspaceSessionHandler private readonly responseStreams: GitResponseStreamRegistry private readonly aiVaultService: ReturnType | null private readonly sessionSearch: { dispose(): void } | null @@ -81,7 +82,7 @@ export class RelayRuntimeServices { const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) const portScanHandler = new PortScanHandler(dispatcher) this.agentExecHandler = new AgentExecHandler(dispatcher) - const workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) + this.workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined this.aiVaultService = hostPlatform ? createRelayAiVaultService(homedir(), hostPlatform) : null @@ -107,7 +108,7 @@ export class RelayRuntimeServices { externalAutomationsHandler, portScanHandler, this.agentExecHandler, - workspaceSessionHandler, + this.workspaceSessionHandler, new AiVaultHandler(dispatcher, { hostPlatform, service: this.aiVaultService ?? undefined @@ -125,6 +126,9 @@ export class RelayRuntimeServices { // Why: answering a stream's request does not prove its detached pumps, descriptors or children // are gone; only these registry drains do. async disposeOwnedProcesses(): Promise { + // The PTY teardown that follows retires every pane on each client; recording that here would + // erase the tabs the clients restore from this snapshot once they reconnect. + this.workspaceSessionHandler.close() const owned = await Promise.allSettled([ this.agentExecHandler.dispose(), this.responseStreams.disposeAllAndWait(), @@ -158,6 +162,7 @@ export class RelayRuntimeServices { } reopenOwnedProcesses(): void { + this.workspaceSessionHandler.reopen() this.agentExecHandler.reopen() this.responseStreams.reopen() this.fsHandler.reopen() diff --git a/src/relay/workspace-session-handler.test.ts b/src/relay/workspace-session-handler.test.ts index 7c3917e9c2a5..38985b1a9337 100644 --- a/src/relay/workspace-session-handler.test.ts +++ b/src/relay/workspace-session-handler.test.ts @@ -35,6 +35,7 @@ describe('WorkspaceSessionHandler', () => { let baseDir: string let dispatcher: RelayDispatcher let written: Buffer[] + let handler: WorkspaceSessionHandler beforeEach(() => { baseDir = mkdtempSync(join(tmpdir(), 'orca-workspace-session-')) @@ -42,7 +43,7 @@ describe('WorkspaceSessionHandler', () => { dispatcher = new RelayDispatcher((data) => { written.push(Buffer.from(data)) }) - new WorkspaceSessionHandler(dispatcher, baseDir) + handler = new WorkspaceSessionHandler(dispatcher, baseDir) }) afterEach(() => { @@ -50,6 +51,35 @@ describe('WorkspaceSessionHandler', () => { rmSync(baseDir, { recursive: true, force: true }) }) + it('records no patch while the relay shuts down, and records again once it reopens', async () => { + // A shutting-down relay kills its PTYs; the clients' retirement of those panes must not erase + // the tabs they restore from this snapshot on reconnect. + const patch = (id: number): Promise => + sendRequest( + dispatcher, + 'workspace.patch', + { + namespace: 'ns', + baseRevision: 0, + clientId: 'client-a', + patch: { kind: 'replace-session', session: { tabsByWorktreePath: {} } } + }, + id + ) + const response = (id: number): unknown => + decodeJsonFrames(written).find( + (frame) => typeof frame === 'object' && frame !== null && 'id' in frame && frame.id === id + ) + + handler.close() + await patch(1) + expect(response(1)).toMatchObject({ result: { ok: false, reason: 'unavailable' } }) + + handler.reopen() + await patch(2) + expect(response(2)).toMatchObject({ result: { ok: true, snapshot: { revision: 1 } } }) + }) + it('stores snapshots atomically and rejects stale revisions', async () => { const session = { activeWorktreePath: '/repo/worktree', diff --git a/src/relay/workspace-session-handler.ts b/src/relay/workspace-session-handler.ts index f9b3ef2e5077..f4dcbf7b5ed6 100644 --- a/src/relay/workspace-session-handler.ts +++ b/src/relay/workspace-session-handler.ts @@ -55,6 +55,7 @@ function sanitizeClientId(value: string): string { export class WorkspaceSessionHandler { private readonly clientsByNamespace = new Map>() + private closed = false constructor( private dispatcher: RelayDispatcher, @@ -125,7 +126,19 @@ export class WorkspaceSessionHandler { return this.read(sanitizeNamespace(params.namespace)) } + /** Refuses patches while the relay shuts down; a deferred shutdown reopens. */ + close(): void { + this.closed = true + } + + reopen(): void { + this.closed = false + } + private async patch(params: Record): Promise { + if (this.closed) { + return { ok: false, reason: 'unavailable', message: 'Relay is shutting down' } + } const namespace = sanitizeNamespace(params.namespace) const current = this.read(namespace) const baseRevision = Number(params.baseRevision) diff --git a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx deleted file mode 100644 index 3b65dbd380f3..000000000000 --- a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx +++ /dev/null @@ -1,496 +0,0 @@ -// @vitest-environment happy-dom - -/** - * The data-safety end of STA-3593. Hydrating a target authorises workspace uploads - * (`use-app-session-persistence.ts`), an upload is a `replace-session` patch - * (`remote-workspace-relay-sync.ts`), and the host applies that by wholesale replacement. So a - * client that hydrated on a snapshot whose tab rows it could not place would upload its incomplete - * projection and DELETE the host tabs it failed to adopt. - * - * That is the asymmetry the design turns on: a suppressed upload is deferred and recoverable, a - * wiped host snapshot is not. This asserts the upload itself never names an unplaced target — - * through the real persistence gate, not the hydrated flag — so it still bites if the gating moves. - */ -import { cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot -} from '../../../shared/remote-workspace-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import type { DirectSshPreparationInput } from '../hooks/direct-ssh-reconnect-coordinator' -import { createRemoteWorkspaceTargetSync } from '../hooks/remote-workspace-target-sync' -import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' -import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -import { useAppStore } from '@/store' -import { useAppSessionPersistence } from './use-app-session-persistence' -import * as workspaceSessionHostPersistence from '../lib/workspace-session-host-persistence' - -const TARGET_ID = 'target-a' -const REPO_ROOT = '/srv/proj' -const HOST_PATH = `${REPO_ROOT}/alpha` -const WORKTREE_ID = `repo-a::${HOST_PATH}` -/** Long enough that any deferred retry or debounce would have fired if one existed. */ -const SETTLE_MS = 12_000 -/** Longer than the post-apply session-write suppression in remote-workspace-snapshot-apply.ts. */ -const WRITE_SUPPRESSION_MS = 1_500 -const DEBOUNCE_MS = 300 - -const owner: DirectSshAuthority = { - targetId: TARGET_ID, - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 1 -} - -function snapshot( - revision = 4, - hostObservationToken = `observation-${revision}` -): RemoteWorkspaceObservedSnapshot { - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken, - session: { - activeWorktreePath: HOST_PATH, - activeTabId: 'T1', - tabsByWorktreePath: { - [HOST_PATH]: [0, 1].map((index) => ({ - id: `T${index + 1}`, - worktreePath: HOST_PATH, - ptyId: `pty-T${index + 1}`, - title: `Terminal ${index + 1}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: index + 1 - })) - }, - terminalLayoutsByTabId: {} - } - } -} - -type UploadArgs = { - hydratedTargetIds?: string[] - expectedRevisionsByTargetId?: Record - expectedHostObservationTokensByTargetId?: Record - session?: unknown -} -type UploadResponse = { targetId: string; result: RemoteWorkspaceObservedPatchResult }[] -const uploads = vi.fn(async (_args: UploadArgs): Promise => []) - -type Deferred = { - promise: Promise - resolve: (value: T) => void -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -function installWindowApi(sessionPatch = vi.fn(async () => {})): void { - ;(window as unknown as { api: unknown }).api = { - session: { patch: sessionPatch, set: vi.fn(async () => {}) }, - remoteWorkspace: { setForConnectedTargets: uploads }, - app: { stageBeforeUnloadSync: vi.fn() }, - ui: { onWindowCloseRequested: vi.fn(() => () => {}) } - } -} - -function seedStore(withHostCatalog: boolean): void { - useAppStore.setState({ - ...useAppStore.getInitialState(), - workspaceSessionReady: true, - hydrationSucceeded: true, - repos: [ - { - id: 'repo-a', - path: REPO_ROOT, - displayName: 'Proj', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_ID, - executionHostId: `ssh:${TARGET_ID}` - } as never - ], - worktreesByRepo: withHostCatalog - ? { - 'repo-a': [ - makeWorktree({ - id: WORKTREE_ID, - repoId: 'repo-a', - path: HOST_PATH, - hostId: `ssh:${TARGET_ID}` - } as never) - ] - } - : {}, - remoteWorkspaceHydratedTargetIds: new Set(), - remoteWorkspaceSyncStatusByTargetId: {}, - tabsByWorktree: {}, - reconnectPersistedTerminals: (async () => {}) as never - }) -} - -function preparationInput( - authority: DirectSshAuthority, - reason: 'workspace-snapshot', - snapshotRevision: number -): DirectSshPreparationInput { - return { - ...authority, - catalogRevision: 1, - repoRefs: [{ repoId: 'repo-a', executionHostId: `ssh:${TARGET_ID}` }], - authorityRequirement: 'required', - reason, - snapshotRevision - } -} - -function createSync( - capturePreparationInput = async ( - authority: DirectSshAuthority, - reason: 'workspace-snapshot', - snapshotRevision: number - ): Promise => preparationInput(authority, reason, snapshotRevision) -) { - return createRemoteWorkspaceTargetSync({ - store: useAppStore, - remoteWorkspace: { - get: vi.fn(async () => snapshot()), - setForConnectedTargets: uploads as never - }, - getCurrentAuthority: () => owner, - isPreparationTokenCurrent: () => true, - capturePreparationInput, - prepareOnly: async (input) => ({ - status: 'degraded' as const, - token: { - authority: owner, - catalogRevision: 1, - repoFingerprint: 'fp', - authorityRequirement: 'required' as const, - snapshotRevision: input.snapshotRevision ?? null, - outcome: 'degraded' as const - }, - repoOutcomes: { - complete: 0, - 'non-authoritative': 1, - 'timed-out': 0, - 'cancel-budget-exhausted': 0, - canceled: 0, - stale: 0, - rejected: 0 - }, - lineageOutcome: 'degraded' as const - }), - finalizeHydratedTerminals: () => 0 - }) -} - -// The initial full write must settle before measured upload responses are installed. -async function renderSettledPersistence() { - const pendingWrites: Promise[] = [] - const persistByHost = workspaceSessionHostPersistence.patchWorkspaceSessionByHost - const observeWrite = vi.spyOn(workspaceSessionHostPersistence, 'patchWorkspaceSessionByHost') - observeWrite.mockImplementation((...args) => { - const result = persistByHost(...args) - pendingWrites.push(result.written) - return result - }) - try { - installWindowApi() - const persistence = renderHook(() => useAppSessionPersistence()) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(pendingWrites).toHaveLength(1) - await Promise.all(pendingWrites) - expect(uploads).not.toHaveBeenCalled() - return persistence - } finally { - await Promise.allSettled(pendingWrites) - observeWrite.mockRestore() - } -} - -/** A local edit the persistence subscriber must want to write, then let it settle. */ -async function touchSessionAndSettle(marker: string): Promise { - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - useAppStore.setState({ activeTabId: marker }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) -} - -async function flushMicrotasks(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} - -function uploadedTargetIds(): string[] { - return uploads.mock.calls.flatMap((call) => call[0]?.hydratedTargetIds ?? []) -} - -function authorizeUploadsAtRevision(revision: number): void { - useAppStore.setState({ - remoteWorkspaceHydratedTargetIds: new Set([TARGET_ID]), - remoteWorkspaceSyncStatusByTargetId: { - [TARGET_ID]: { - phase: 'synced', - direction: 'pull', - revision, - hostObservationToken: `observation-${revision}` - } - } - }) -} - -let nextCaseTime = Date.now() - -beforeEach(() => { - vi.useFakeTimers({ now: nextCaseTime }) - uploads.mockReset() - uploads.mockResolvedValue([]) - installWindowApi() -}) - -afterEach(async () => { - try { - cleanup() - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - expect(isDirectSshRemoteWorkspaceApplyInProgress()).toBe(false) - } finally { - // The shared apply deadline must not outlive a rollback of this fixture's clock. - nextCaseTime = Date.now() - vi.useRealTimers() - } -}) - -describe('uploads from a client that could not place the host tabs', () => { - it('cancels a captured upload when a snapshot arrives during its local write', async () => { - seedStore(true) - authorizeUploadsAtRevision(3) - const pendingLocalWrite = deferred() - const sessionPatch = vi.fn(() => pendingLocalWrite.promise) - installWindowApi(sessionPatch) - const persistence = renderHook(() => useAppSessionPersistence()) - - useAppStore.setState({ activeTabId: 'before-snapshot' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(sessionPatch).toHaveBeenCalled() - - const pendingCapture = deferred() - const sync = createSync(() => pendingCapture.promise) - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - pendingLocalWrite.resolve() - await Promise.resolve() - await Promise.resolve() - - expect( - uploadedTargetIds(), - 'an upload captured before the incoming revision overwrote its cached tabs' - ).not.toContain(TARGET_ID) - - sync.stop() - pendingCapture.resolve(preparationInput(owner, 'workspace-snapshot', 4)) - await pendingApply - persistence.unmount() - }) - - it('excludes an incoming snapshot target while preparation is pending', async () => { - seedStore(true) - authorizeUploadsAtRevision(3) - const persistence = renderHook(() => useAppSessionPersistence()) - const pendingCapture = deferred() - const sync = createSync(() => pendingCapture.promise) - - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - await touchSessionAndSettle('capture-pending') - - expect( - uploadedTargetIds(), - 'the cached incoming revision was overwritten before its tabs could be applied' - ).not.toContain(TARGET_ID) - - pendingCapture.resolve(preparationInput(owner, 'workspace-snapshot', 4)) - await pendingApply - sync.stop() - persistence.unmount() - }) - - it('issues no upload for a target whose host tabs it could not place', async () => { - seedStore(false) - const persistence = renderHook(() => useAppSessionPersistence()) - const sync = createSync() - - // Deliberately not asserting the placement verdict first: the oracle is the upload, and a - // precondition on the verdict would fail ahead of it and hide whether the upload gate holds. - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - await vi.advanceTimersByTimeAsync(10_000) - await pendingApply - expect( - useAppStore.getState().tabsByWorktree[WORKTREE_ID], - 'the host named two terminals and this client placed neither' - ).toBeUndefined() - - // Before the client has settled: it is not authoritative for this target yet. - await touchSessionAndSettle('mid-chain') - expect( - uploadedTargetIds(), - 'a client that adopted none of the host tabs uploaded over them' - ).not.toContain(TARGET_ID) - - // Still no upload once everything has settled: not adopting is not permission to overwrite the host. - await vi.advanceTimersByTimeAsync(SETTLE_MS) - expect( - useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]?.phase, - 'a client that placed none of the host tabs reported a healthy sync' - ).not.toBe('synced') - await touchSessionAndSettle('settled') - expect( - uploadedTargetIds(), - 'settling authorised a replace-session upload built from an incomplete projection' - ).not.toContain(TARGET_ID) - - sync.stop() - persistence.unmount() - }) - - it('does upload for a target whose host tabs it did place', async () => { - seedStore(true) - const persistence = renderHook(() => useAppSessionPersistence()) - const sync = createSync() - - await sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - // Adoption is the observable outcome; the apply reports nothing back by design. - expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ - 'T1', - 'T2' - ]) - - await touchSessionAndSettle('placed') - - // Without this the suppression assertions above would pass on a harness that never uploads. - expect(uploadedTargetIds()).toContain(TARGET_ID) - expect(uploads).toHaveBeenCalledWith( - expect.objectContaining({ - expectedRevisionsByTargetId: { [TARGET_ID]: 4 }, - expectedHostObservationTokensByTargetId: { - [TARGET_ID]: 'observation-4' - } - }) - ) - - sync.stop() - persistence.unmount() - }) - - it('keeps a later same-lineage upload after the earlier result advances the revision', async () => { - seedStore(true) - const persistence = await renderSettledPersistence() - authorizeUploadsAtRevision(7) - const secondLocalWrite = deferred() - let localWriteCount = 0 - const sessionPatch = vi.fn(() => { - localWriteCount += 1 - return localWriteCount === 2 ? secondLocalWrite.promise : Promise.resolve() - }) - const firstUpload = deferred() - uploads.mockImplementationOnce(() => firstUpload.promise) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(9, 'observation-7') } - } - ]) - installWindowApi(sessionPatch) - - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - useAppStore.setState({ activeTabId: 'first-local-write' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(uploads).toHaveBeenCalledOnce() - - useAppStore.setState({ activeTabId: 'second-local-write' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(sessionPatch).toHaveBeenCalledTimes(2) - expect(uploads).toHaveBeenCalledOnce() - - firstUpload.resolve([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(8, 'observation-7') } - } - ]) - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 8, - hostObservationToken: 'observation-7' - }) - - secondLocalWrite.resolve() - await flushMicrotasks() - expect(uploads).toHaveBeenCalledTimes(2) - expect(uploads.mock.calls[1][0]).toMatchObject({ - expectedRevisionsByTargetId: { [TARGET_ID]: 7 }, - expectedHostObservationTokensByTargetId: { [TARGET_ID]: 'observation-7' } - }) - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 9, - hostObservationToken: 'observation-7' - }) - - persistence.unmount() - }) - - it('retains transient upload authority so the next local edit retries', async () => { - seedStore(true) - const persistence = await renderSettledPersistence() - authorizeUploadsAtRevision(7) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: false, reason: 'unavailable', message: 'temporary relay failure' } - } - ]) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(8, 'observation-7') } - } - ]) - - await touchSessionAndSettle('transient-failure') - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'offline', - revision: 7, - hostObservationToken: 'observation-7' - }) - - await touchSessionAndSettle('retry-after-transient-failure') - await flushMicrotasks() - expect(uploads).toHaveBeenCalledTimes(2) - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 8, - hostObservationToken: 'observation-7' - }) - - persistence.unmount() - }) -}) diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 6574438265de..ee36a2c89467 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -4,7 +4,6 @@ import { isDirectSshRemoteWorkspaceApplyInProgress, onDirectSshRemoteWorkspaceApplyWindowClosed } from '../hooks/remote-workspace-snapshot-apply' -import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -37,84 +36,13 @@ import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_ABORTED_EVENT, ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' -import type { AppState } from '../store/types' -import type { DirectSshLayoutEdit } from '../store/terminals/terminal-state' -import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' -import { applyRemoteWorkspacePushStatus } from '../hooks/remote-workspace-push-status' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 -type RemoteWorkspaceUploadAuthority = { - targetId: string - revision: number - updatedAt?: number - hostObservationToken: string -} - -function captureRemoteWorkspaceUploadAuthorities( - state: AppState -): RemoteWorkspaceUploadAuthority[] { - return Array.from(state.remoteWorkspaceHydratedTargetIds).flatMap((targetId) => { - const syncStatus = state.remoteWorkspaceSyncStatusByTargetId[targetId] - const revision = syncStatus?.revision - const hostObservationToken = syncStatus?.hostObservationToken - if ( - syncStatus?.phase === 'conflict' || - typeof revision !== 'number' || - !Number.isSafeInteger(revision) || - revision < 0 || - typeof hostObservationToken !== 'string' || - hostObservationToken.length === 0 - ) { - return [] - } - return [ - { - targetId, - revision, - updatedAt: syncStatus.updatedAt, - hostObservationToken - } - ] - }) -} - -function remoteWorkspaceUploadAuthorityIsCurrent( - state: AppState, - authority: RemoteWorkspaceUploadAuthority -): boolean { - const status = state.remoteWorkspaceSyncStatusByTargetId[authority.targetId] - return ( - state.remoteWorkspaceHydratedTargetIds.has(authority.targetId) && - status?.phase !== 'conflict' && - status?.hostObservationToken === authority.hostObservationToken - ) -} - -function captureUploadedDirectSshLayoutEdits( - pendingLayoutEdits: AppState['pendingDirectSshLayoutEditsByTabId'], - targetId: string, - result: RemoteWorkspaceObservedPatchResult | undefined -): Record { - if (!result?.ok) { - return {} - } - return Object.fromEntries( - Object.entries(pendingLayoutEdits).flatMap(([tabId, entry]) => { - const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] - return entry.targetId === targetId && - uploaded && - terminalLayoutNodeEqual(entry.root, uploaded.root) - ? [[tabId, entry]] - : [] - }) - ) -} - /** - * Writes durable renderer session state to disk: the debounced per-host writer, the remote - * workspace upload chain, and the synchronous shutdown checkpoint. + * Writes durable renderer session state to disk: the debounced per-host writer and the + * synchronous shutdown checkpoint. Main exports what it holds to SSH hosts. */ export function useAppSessionPersistence(): void { useEffect(() => registerUpdaterBeforeUnloadBypass(), []) @@ -126,79 +54,9 @@ export function useAppSessionPersistence(): void { shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed, onPersistError: (error) => console.warn('[session] Session patch failed:', error), - persist: ({ patch }) => { - const state = useAppStore.getState() - // Why: route each host's worktree-scoped slice to its own partition; the upload chain below orders after the local write. - const { localWrite, written } = patchWorkspaceSessionByHost( - window.api.session, - patch, - state - ) - const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state) - const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId - if (uploadAuthorities.length > 0) { - void (async () => { - try { - await localWrite - const currentState = useAppStore.getState() - const currentAuthorities = uploadAuthorities.filter((authority) => - remoteWorkspaceUploadAuthorityIsCurrent(currentState, authority) - ) - if (currentAuthorities.length === 0) { - return - } - const hydratedTargetIds = currentAuthorities.map(({ targetId }) => targetId) - const expectedRevisionsByTargetId = Object.fromEntries( - currentAuthorities.map(({ targetId, revision }) => [targetId, revision]) - ) - const expectedHostObservationTokensByTargetId = Object.fromEntries( - currentAuthorities.map(({ targetId, hostObservationToken }) => [ - targetId, - hostObservationToken - ]) - ) - const results = await window.api.remoteWorkspace?.setForConnectedTargets({ - hydratedTargetIds, - expectedRevisionsByTargetId, - expectedHostObservationTokensByTargetId - }) - const resultState = useAppStore.getState() - const currentAuthorityByTargetId = new Map( - currentAuthorities.map((authority) => [authority.targetId, authority]) - ) - for (const { targetId, result } of results ?? []) { - const authority = currentAuthorityByTargetId.get(targetId) - if (authority && remoteWorkspaceUploadAuthorityIsCurrent(resultState, authority)) { - if (result?.ok) { - resultState.acknowledgeDirectSshLayoutEdits( - captureUploadedDirectSshLayoutEdits(pendingLayoutEdits, targetId, result) - ) - } - applyRemoteWorkspacePushStatus(resultState, targetId, result, authority) - } - } - } catch (err) { - const errorState = useAppStore.getState() - for (const authority of uploadAuthorities) { - if (!remoteWorkspaceUploadAuthorityIsCurrent(errorState, authority)) { - continue - } - const currentStatus = - errorState.remoteWorkspaceSyncStatusByTargetId[authority.targetId] - errorState.setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'error', - direction: 'push', - revision: currentStatus?.revision ?? authority.revision, - updatedAt: currentStatus?.updatedAt ?? authority.updatedAt, - hostObservationToken: authority.hostObservationToken, - message: err instanceof Error ? err.message : 'Workspace upload failed' - }) - } - } - })() - } - return written - } + // Why: route each host's worktree-scoped slice to its own partition. + persist: ({ patch }) => + patchWorkspaceSessionByHost(window.api.session, patch, useAppStore.getState()) }) }, []) diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 0c0ab0dd5436..4d0de1a78f35 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -9,6 +9,7 @@ import { waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' +import { followTerminalTopology } from '../lib/terminal-topology-follow' import { collectFolderWorkspaceKeysFromSession, collectWorktreeHydrationRepoIdsFromSession @@ -22,6 +23,7 @@ import { import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery' import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore' import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets' +import { listRuntimeSessionHostIdsForStartup } from '../startup/startup-runtime-session-hosts' import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance' import { getSystemPrefersDark } from '../lib/terminal-theme' import { @@ -31,25 +33,12 @@ import { import { getRepoExecutionHostId, isRuntimeOwnedSshTargetId, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId + parseExecutionHostId } from '../../../shared/execution-host' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' import { ensureLocalRuntimeCapabilities } from '../runtime/local-runtime-capabilities' -async function listRuntimeSessionHostIdsForStartup(): Promise { - try { - return (await window.api.runtimeEnvironments.list()).map((environment) => - toRuntimeExecutionHostId(environment.id) - ) - } catch (err) { - console.warn('Failed to list runtime session hosts for startup:', err) - return [] - } -} - /** * Runs the renderer's one-shot boot chain: settings, persisted UI, the local repo catalog, * the workspace session, SSH reconnect, and terminal restoration — then unlocks the session @@ -83,6 +72,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta let uiHydrated = false // Why (issue #1158): track whether success-path reconnect started so the catch doesn't re-run it — re-entering on partially-mutated state would double-set ptyIds and drain pending* twice. let reconnectStarted = false + let terminalTopologyFollowed = false void (async () => { const startupStartedAt = performance.now() logRendererStartupDiagnostic('startup-chain-start') @@ -214,6 +204,15 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta useAppStore.getState().reconcileWorktreeTabModels ) }) + // Why after hydration: hydrating from the earlier session read would roll back a newer slice. + await timeRendererStartupStep('follow-terminal-topology', () => + followTerminalTopology( + window.api.session, + useAppStore.getState().applyTerminalTopologySlices, + abortController.signal + ) + ) + terminalTopologyFollowed = true await timeRendererStartupStep('prepare-terminal-startup-restoration', () => window.api.app.prepareTerminalStartupRestoration() ) @@ -351,6 +350,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta error, uiHydrated, reconnectStarted, + terminalTopologyFollowed, isCancelled: () => cancelled, hydratePersistedUI: actions.hydratePersistedUI, reconnectPersistedTerminals: actions.reconnectPersistedTerminals, diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts index 1aa4adfcd9b9..9aa2951b6168 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts @@ -143,7 +143,8 @@ describe('codex detached pane restart executor', () => { const state = useAppStore.getState() expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY]) expect(state.tabsByWorktree.wt1?.[0]?.ptyId).toBe(NEW_PTY) - expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: NEW_PTY }) + // The pane's binding is main's: its spawn commit records the replacement and its push brings it. + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: OLD_PTY }) expect(state.pendingCodexPaneRestartIds).toEqual({}) expect(state.suppressedPtyExitIds[OLD_PTY]).toBeUndefined() // The whole point: the restart completed, so nothing may block input anymore. @@ -165,7 +166,8 @@ describe('codex detached pane restart executor', () => { const state = useAppStore.getState() expect(window.api.pty.kill).not.toHaveBeenCalled() expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY]) - expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: NEW_PTY }) + // The pane's binding is main's: its spawn commit records the replacement and its push brings it. + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: OLD_PTY }) expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[NEW_PTY])).toBe(false) }) @@ -340,8 +342,9 @@ describe('codex detached pane restart executor', () => { const layout = useAppStore.getState().terminalLayoutsByTabId['tab-1'] expect(layout?.root?.type).toBe('split') + // Main's binding until its push: the window rewrites neither leaf. expect(layout?.ptyIdsByLeafId).toEqual({ - [LEAF_ID]: NEW_PTY, + [LEAF_ID]: OLD_PTY, [SIBLING_LEAF]: 'wt1@@sibling' }) // Split-pane safety: only the codex pane's PTY is replaced. @@ -395,7 +398,7 @@ describe('codex detached pane restart executor', () => { expect(window.api.pty.kill).not.toHaveBeenCalled() const state = useAppStore.getState() expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY]) - expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe(NEW_PTY) + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe(OLD_PTY) expect(state.pendingCodexPaneRestartIds).toEqual({}) } finally { unregister() diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index 027e6ee156ab..ba5cab00fd02 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -25,7 +25,7 @@ import { hasCachedWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { ptyDataHandlers, unregisterPtyDataHandlers } from './pty-dispatcher' -import { collectLeafIds } from './terminal-pane-layout-tree' +import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' import { disposeParkedTerminalWatchersForPtyIds } from './terminal-parked-watcher-registry' @@ -324,6 +324,7 @@ function restartReplacesLayoutRoot( ) } +// Main binds the replacement, so its push brings the leaf's new PTY id. function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: string): void { const store = useAppStore.getState() const layout = store.terminalLayoutsByTabId[tabId] @@ -332,9 +333,7 @@ function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: stri tabId, singlePaneLayoutSnapshot(leafId, newPtyId, layout?.titlesByLeafId?.[leafId] ?? null) ) - return } - store.replaceTerminalLayoutPanePtyId(tabId, leafId, newPtyId) } function reapUnboundCodexPty(ptyId: string, reason: string): void { diff --git a/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts index 18bd95c0fea3..89bdc538f950 100644 --- a/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts +++ b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts @@ -5,6 +5,7 @@ spec, where they would drift apart. */ import { afterEach, beforeEach, vi } from 'vitest' import type { AppState } from '@/store/types' +import type { PendingTerminalPane } from '@/store/terminals/terminal-pending-panes' import type { TerminalTabRetirementState } from '@/store/slices/terminal-tab-retirement' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { PaneManager } from '@/lib/pane-manager/pane-manager' @@ -103,12 +104,18 @@ export async function preparePendingSplitClose( } } } + const pendingTerminalPanes: PendingTerminalPane[] = [] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The real close paths read only this retirement state and the supplied actions. store.current = Object.assign(state, { setCacheTimerStartedAt: vi.fn(), dropAgentStatus: vi.fn(), retireAgentPaneAuthority: vi.fn(), - suppressPtyExit: vi.fn() + suppressPtyExit: vi.fn(), + pendingTerminalPanes, + markPendingTerminalPane: vi.fn((entry: PendingTerminalPane) => + pendingTerminalPanes.push(entry) + ), + settlePendingTerminalPane: vi.fn() }) as unknown as AppState const spawn = Promise.withResolvers() vi.mocked(window.api.pty.spawn).mockReturnValueOnce(spawn.promise) @@ -200,6 +207,7 @@ export async function preparePendingSplitClose( controller, actions, state, + pendingTerminalPanes, tabId, leafId, siblingLeafId diff --git a/src/renderer/src/components/terminal-pane/pending-split-close.test.ts b/src/renderer/src/components/terminal-pane/pending-split-close.test.ts index 3ab94611f941..7e0e98d8215d 100644 --- a/src/renderer/src/components/terminal-pane/pending-split-close.test.ts +++ b/src/renderer/src/components/terminal-pane/pending-split-close.test.ts @@ -16,7 +16,10 @@ it.each(['reattach', 'cold-restore-new', 'ordinary-fresh'] as const)( expect(p.transport.getPtyId()).toBeNull() p.actions.executeClosePane(1) expect(p.transports.has(1)).toBe(false) - expect(p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId?.[p.leafId]).toBeUndefined() + // The leaf's binding is main's until its push drops the leaf; the window only hides the pane. + expect(p.pendingTerminalPanes).toContainEqual( + expect.objectContaining({ tabId: p.tabId, leafId: p.leafId, change: 'remove' }) + ) expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith('pty-restored') p.spawn.resolve(replyFor(kind)) await p.connecting @@ -83,7 +86,8 @@ it.each(['same-leaf', 'different-tab', 'new-transport-map'] as const)( const { createIpcPtyTransport } = await import('./pty-transport') const replacement = createIpcPtyTransport({}) if (owner === 'same-leaf') { - p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId = { [p.leafId]: 'pty-restored' } + // Main refused the close, so the pane is back and its leaf owns the PTY again. + p.pendingTerminalPanes.length = 0 } else if (owner === 'different-tab') { p.state.tabsByWorktree.workspace.push(makeCloseTestTab('new-owner', 'pty-restored')) } else { @@ -203,6 +207,5 @@ it('commits the explicit split close in main by its leaf without waiting for its target: { kind: 'pane', tabId: p.tabId, leafId: p.leafId } }) expect(p.transports.has(1)).toBe(false) - expect(p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId?.[p.leafId]).toBeUndefined() expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith('pty-restored') }) diff --git a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts index a3235fea66a7..39139e4c796a 100644 --- a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts +++ b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts @@ -3,6 +3,7 @@ import { getTerminalPtyOwnershipIdentity } from '@/store/slices/terminal-tab-retirement' import { startTerminalTabProviderRetirement } from '@/store/terminals/terminal-tab-close-providers' +import { pendingTerminalLeafIds } from '@/store/terminals/terminal-pending-panes' import { terminalPaneHasOtherOwner, type UnboundTerminalPaneRetirement @@ -56,8 +57,10 @@ export function retireUnboundIpcTerminalPane(args: UnboundTerminalPaneRetirement if (returnedPtyId !== requestedPtyId) { return false } - // A replacement can own even this same leaf by the time the reply arrives. - if (!hasOtherOwner()) { + // A replacement can own even this same leaf by the time the reply arrives, but only once + // a refused close gives it back: while it is closing, its binding is main's stale copy. + const closing = pendingTerminalLeafIds(getState().pendingTerminalPanes, tabId, 'remove') + if (!hasOtherOwner(closing.has(leafId) ? leafId : undefined)) { requestRetirement() } return true diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx index c7a2c58c7197..df39803c2cb1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx @@ -6,15 +6,21 @@ import { useTerminalPaneLayoutPersistence } from './use-terminal-pane-layout-per const LEFT = '11111111-1111-4111-8111-111111111111' const RIGHT = '22222222-2222-4222-8222-222222222222' -vi.mock('../../store', () => ({ - useAppStore: { getState: () => ({ terminalLayoutsByTabId: {} }) } -})) +const store = vi.hoisted(() => { + const terminalLayoutsByTabId: Record = {} + return { + terminalLayoutsByTabId, + markPendingTerminalPane: vi.fn(), + settlePendingTerminalPane: vi.fn() + } +}) +vi.mock('../../store', () => ({ useAppStore: { getState: () => store } })) vi.mock('@/runtime/web-runtime-session', () => ({ clearWebRuntimeTerminalBuffer: () => true })) vi.mock('@/lib/pane-manager/terminal-scrollback-clear', () => ({ clearTerminalScrollbackAndFollowOutput: vi.fn() })) -function renderPersistence() { +function renderPersistence(ptyIdForPane = (paneId: number) => `remote:host:${paneId}`) { const container = document.createElement('div') const split = document.createElement('div') split.className = 'pane-split' @@ -46,7 +52,7 @@ function renderPersistence() { paneTitles: { 1: 'build' }, paneTitlesRef: { current: { 1: 'build' } }, paneTransportsRef: { - current: new Map(panes.map((pane) => [pane.id, { getPtyId: () => `remote:host:${pane.id}` }])) + current: new Map(panes.map((pane) => [pane.id, { getPtyId: () => ptyIdForPane(pane.id) }])) }, remotePaneLayoutPusherRef: { current: { push } }, removedTitleLeafIdsRef: { current: new Set() }, @@ -67,7 +73,11 @@ function renderPersistence() { return { hook, push, setTabLayout, pushedIntents, panes } } -afterEach(cleanup) +afterEach(() => { + cleanup() + store.terminalLayoutsByTabId = {} + store.markPendingTerminalPane.mockClear() +}) describe('terminal layout gesture persist', () => { it('saves the same layout for a gesture and an automatic persist and marks only the push', () => { @@ -107,4 +117,56 @@ describe('terminal layout gesture persist', () => { expect(pushedIntents()).toEqual(['gesture', 'gesture']) }) + + it('never commits a remote tab to main; its host takes the push', () => { + const { hook } = renderPersistence() + hook.result.current.persistLayoutSnapshot('gesture') + expect(store.markPendingTerminalPane).not.toHaveBeenCalled() + }) +}) + +describe('local terminal layout gestures', () => { + const local = (paneId: number): string => `pty-live-${paneId}` + + it('commits a gesture that moved the tree to main, once', () => { + store.terminalLayoutsByTabId = { + tab: { root: { type: 'leaf', leafId: LEFT }, activeLeafId: LEFT, expandedLeafId: null } + } + const { hook, setTabLayout } = renderPersistence(local) + store.markPendingTerminalPane.mockClear() + + hook.result.current.persistLayoutSnapshot('gesture') + + const saved = setTabLayout.mock.calls.at(-1)?.[1] + expect(store.markPendingTerminalPane).toHaveBeenCalledExactlyOnceWith({ + worktreeId: 'wt', + tabId: 'tab', + change: 'layout', + root: saved.root + }) + }) + + it('commits nothing for an automatic persist or a gesture that left the tree as it was', () => { + const { hook, setTabLayout } = renderPersistence(local) + store.terminalLayoutsByTabId = { tab: setTabLayout.mock.calls.at(-1)?.[1] } + + hook.result.current.persistLayoutSnapshot() + hook.result.current.removePaneTitle(1) + + expect(store.markPendingTerminalPane).not.toHaveBeenCalled() + }) + + it("saves main's bindings, not the live transports' PTY ids", () => { + store.terminalLayoutsByTabId = { + tab: { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: 'pty-main' } + } + } + const { hook, setTabLayout } = renderPersistence(local) + hook.result.current.persistLayoutSnapshot() + expect(setTabLayout.mock.calls.at(-1)?.[1].ptyIdsByLeafId).toEqual({ [LEFT]: 'pty-main' }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts index ffa88d60783d..fdca140c95df 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -14,7 +14,7 @@ export type DetachedTerminalLayoutLeaf = { ptyId: string | null } -function removeLeafFromTree( +export function removeLeafFromTree( node: TerminalPaneLayoutNode, leafId: string ): { node: TerminalPaneLayoutNode | null; removed: boolean } { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts index 7ea60830f9ca..b61c511c2cc1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -4,7 +4,7 @@ import type { } from '../../../../shared/terminal-tab-types' import { isTerminalLeafId, type TerminalLeafId } from '../../../../shared/stable-pane-id' import { mintStablePaneId } from '@/lib/pane-manager/mint-stable-pane-id' -import { normalizeTerminalLayoutPtyOwnership } from './terminal-layout-pty-ownership' +import { normalizeTerminalLayoutPtyOwnership } from '../../../../shared/terminal-layout-pty-ownership' const EMPTY_TERMINAL_LAYOUT: TerminalLayoutSnapshot = { root: null, diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts index ddacb05ccd88..c162de1fc675 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { normalizeTerminalLayoutSnapshot } from './terminal-layout-leaf-ids' -import { normalizeTerminalLayoutPtyOwnership } from './terminal-layout-pty-ownership' +import { normalizeTerminalLayoutPtyOwnership } from '../../../../shared/terminal-layout-pty-ownership' const LEAF_1 = '11111111-1111-4111-8111-111111111111' const LEAF_2 = '22222222-2222-4222-8222-222222222222' diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts index 814ca8d974f7..c9a32cc64b5f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts @@ -1,45 +1,10 @@ import { describe, expect, it } from 'vitest' import { - isHostAuthoritativeLayout, planTerminalLiveLayoutInsertions, - planTerminalLiveLayoutRemovals, - selectRetiredPaneIds, - trackRetiredLeafIds + planTerminalLiveLayoutRemovals } from './terminal-live-layout-reconciliation' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' -const LOCAL_PTY = 'pty-local-123' -const REMOTE_PTY = 'remote:env-1@@term_abc' - -describe('isHostAuthoritativeLayout', () => { - it('is true for any web client regardless of pty ids', () => { - expect(isHostAuthoritativeLayout({ isWebClient: true, ptyIdsByLeafId: { a: LOCAL_PTY } })).toBe( - true - ) - }) - - it('is true for a desktop client when a leaf has a remote-runtime pty (remote server tab)', () => { - // Why: the split-render bug — desktop viewing a remote server got the host - // layout but skipped reconciliation, so the split never rendered. - expect( - isHostAuthoritativeLayout({ - isWebClient: false, - ptyIdsByLeafId: { a: LOCAL_PTY, b: REMOTE_PTY } - }) - ).toBe(true) - }) - - it('is false for a desktop client with only local ptys (local tab splits directly)', () => { - expect( - isHostAuthoritativeLayout({ isWebClient: false, ptyIdsByLeafId: { a: LOCAL_PTY } }) - ).toBe(false) - }) - - it('is false for a desktop client with no pty ids', () => { - expect(isHostAuthoritativeLayout({ isWebClient: false, ptyIdsByLeafId: undefined })).toBe(false) - }) -}) - describe('planTerminalLiveLayoutInsertions', () => { it('plans a host-added split leaf from an already-mounted source leaf', () => { const layout: TerminalPaneLayoutNode = { @@ -237,8 +202,7 @@ describe('planTerminalLiveLayoutInsertions', () => { }) describe('planTerminalLiveLayoutRemovals', () => { - // Every mounted leaf counted as retired: the layout alone must veto removals. - const BOTH = new Set(['leaf-a', 'leaf-b']) + const NONE = new Set() it('plans the mounted leaf a host-retired layout no longer names', () => { // Why: closing one pane of a remote-server split kills its PTY on the host, @@ -246,9 +210,7 @@ describe('planTerminalLiveLayoutRemovals', () => { // for the retired leaf must go too, or it lingers as a blank ghost. const layout: TerminalPaneLayoutNode = { type: 'leaf', leafId: 'leaf-a' } - expect( - planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-b'], new Set(['leaf-b'])) - ).toEqual(['leaf-b']) + expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-b'], NONE)).toEqual(['leaf-b']) }) it('plans nothing when every mounted leaf is still in the layout', () => { @@ -259,147 +221,21 @@ describe('planTerminalLiveLayoutRemovals', () => { second: { type: 'leaf', leafId: 'leaf-b' } } - expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-b'], BOTH)).toEqual([]) - expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a'], BOTH)).toEqual([]) + expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-b'], NONE)).toEqual([]) + expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a'], NONE)).toEqual([]) }) it('plans nothing for an empty layout', () => { - expect(planTerminalLiveLayoutRemovals(null, ['leaf-a'], BOTH)).toEqual([]) - expect(planTerminalLiveLayoutRemovals(undefined, ['leaf-a'], BOTH)).toEqual([]) - }) - - it('leaves a mounted leaf the host has never named alone', () => { - // Why: a pane the client just split is still spawning, so its transport has - // no PTY yet, and a host snapshot that lands mid-spawn does not name it. - // Only a leaf the host named before can be one the host retired. - const layout: TerminalPaneLayoutNode = { type: 'leaf', leafId: 'leaf-a' } - - expect( - planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-new'], new Set(['leaf-a'])) - ).toEqual([]) - expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-new'], new Set())).toEqual([]) - }) -}) - -describe('selectRetiredPaneIds', () => { - const view = (ptyIdsByPane: Record) => ({ - paneCount: Object.keys(ptyIdsByPane).length, - paneIdForLeaf: (leafId: string) => (leafId === 'leaf-b' ? 2 : leafId === 'leaf-c' ? 3 : null), - ptyIdForPane: (paneId: number) => ptyIdsByPane[paneId] - }) - - it('closes the pane whose transport lost its PTY', () => { - // Why: the host retired the leaf because its PTY ended, so a pane that no - // longer has one is exactly the blank ghost the layout stopped naming. - expect(selectRetiredPaneIds(['leaf-b'], view({ 1: 'pty-a', 2: null }))).toEqual([2]) - }) - - it('keeps a pane still bound to a PTY or not yet attached to a transport', () => { - // A stale snapshot may simply not name a live pane yet; a pane with no - // transport is still mounting. Neither is evidence of a retired leaf. - expect(selectRetiredPaneIds(['leaf-b'], view({ 1: 'pty-a', 2: 'pty-b' }))).toEqual([]) - expect(selectRetiredPaneIds(['leaf-b'], view({ 1: 'pty-a', 2: undefined }))).toEqual([]) - }) - - it('never removes the last pane on the tab', () => { - expect(selectRetiredPaneIds(['leaf-b'], view({ 2: null }))).toEqual([]) - expect(selectRetiredPaneIds(['leaf-b', 'leaf-c'], view({ 2: null, 3: null }))).toEqual([2]) - }) - - it('skips a leaf that has no mounted pane', () => { - expect(selectRetiredPaneIds(['leaf-x'], view({ 1: 'pty-a', 2: null }))).toEqual([]) - }) -}) - -describe('trackRetiredLeafIds', () => { - it('retires a mounted leaf the host dropped from its layout', () => { - expect( - trackRetiredLeafIds({ - retiredLeafIds: new Set(), - previousLayoutLeafIds: new Set(['leaf-a', 'leaf-b']), - layoutLeafIds: new Set(['leaf-a']), - mountedLeafIds: ['leaf-a', 'leaf-b'] - }) - ).toEqual(new Set(['leaf-b'])) + expect(planTerminalLiveLayoutRemovals(null, ['leaf-a'], NONE)).toEqual([]) + expect(planTerminalLiveLayoutRemovals(undefined, ['leaf-a'], NONE)).toEqual([]) }) - it('keeps a retired leaf until its pane is gone', () => { - // Why: the removal may have been skipped while the transport still held its - // PTY; the next reconciliation must still see the leaf as retired. - const args = { - retiredLeafIds: new Set(['leaf-b']), - previousLayoutLeafIds: new Set(['leaf-a']), - layoutLeafIds: new Set(['leaf-a']) - } - expect(trackRetiredLeafIds({ ...args, mountedLeafIds: ['leaf-a', 'leaf-b'] })).toEqual( - new Set(['leaf-b']) - ) - expect(trackRetiredLeafIds({ ...args, mountedLeafIds: ['leaf-a'] })).toEqual(new Set()) - }) - - it('forgets a retired leaf the host names again', () => { - expect( - trackRetiredLeafIds({ - retiredLeafIds: new Set(['leaf-b']), - previousLayoutLeafIds: new Set(['leaf-a']), - layoutLeafIds: new Set(['leaf-a', 'leaf-b']), - mountedLeafIds: ['leaf-a', 'leaf-b'] - }) - ).toEqual(new Set()) - }) - - it('never retires a leaf the host has not named', () => { - expect( - trackRetiredLeafIds({ - retiredLeafIds: new Set(), - previousLayoutLeafIds: new Set(['leaf-a']), - layoutLeafIds: new Set(['leaf-a']), - mountedLeafIds: ['leaf-a', 'leaf-new'] - }) - ).toEqual(new Set()) - }) -}) - -describe('host retirement that lands before the transport teardown', () => { - it('removes the pane on the reconciliation after its PTY clears', () => { - // Why: the host drops the leaf and ends its PTY in one step, but the two - // reach the client separately. If the layout arrives first the pane still - // holds its PTY and must not be closed yet; once the exit lands and rewrites - // the layout bindings, the effect runs again and must close it then. + it('keeps a pending pane the layout does not name yet', () => { + // Why: a pane this window just split is not in main's layout until main binds it. const layout: TerminalPaneLayoutNode = { type: 'leaf', leafId: 'leaf-a' } - const mounted = ['leaf-a', 'leaf-b'] - const paneIdForLeaf = (leafId: string) => - leafId === 'leaf-a' ? 1 : leafId === 'leaf-b' ? 2 : null - let retired = trackRetiredLeafIds({ - retiredLeafIds: new Set(), - previousLayoutLeafIds: new Set(mounted), - layoutLeafIds: new Set(['leaf-a']), - mountedLeafIds: mounted - }) - let removals = planTerminalLiveLayoutRemovals(layout, mounted, retired) - expect(removals).toEqual(['leaf-b']) expect( - selectRetiredPaneIds(removals, { - paneCount: 2, - paneIdForLeaf, - ptyIdForPane: (paneId) => (paneId === 2 ? 'pty-b' : 'pty-a') - }) + planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-new'], new Set(['leaf-new'])) ).toEqual([]) - - retired = trackRetiredLeafIds({ - retiredLeafIds: retired, - previousLayoutLeafIds: new Set(['leaf-a']), - layoutLeafIds: new Set(['leaf-a']), - mountedLeafIds: mounted - }) - removals = planTerminalLiveLayoutRemovals(layout, mounted, retired) - expect( - selectRetiredPaneIds(removals, { - paneCount: 2, - paneIdForLeaf, - ptyIdForPane: (paneId) => (paneId === 2 ? null : 'pty-a') - }) - ).toEqual([2]) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts index 859f6c971688..b3bb4845225a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts @@ -1,28 +1,11 @@ import type { + TerminalLayoutSnapshot, TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/terminal-tab-types' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' -import { collectLeafIds } from './terminal-pane-layout-tree' - -/** - * Whether a tab's split layout is owned by a host (web/mobile clients, or a - * desktop client viewing a remote "Orca server" worktree) rather than built - * locally. Such layouts arrive via the host snapshot, so the live-layout - * reconciler must materialize their panes. A desktop client only needs this for - * remote-runtime tabs — local tabs split their panes directly. - */ -export function isHostAuthoritativeLayout(args: { - isWebClient: boolean - ptyIdsByLeafId: Record | undefined -}): boolean { - if (args.isWebClient) { - return true - } - return Object.values(args.ptyIdsByLeafId ?? {}).some( - (ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId) - ) -} +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' +import { removeLeafFromTree } from './terminal-layout-leaf-detach' export type TerminalLiveLayoutInsertion = { sourceLeafId: string @@ -87,25 +70,21 @@ function mountedLeafIdsIn( } /** - * Mounted leaves the host layout no longer names. The host retires a leaf when - * its PTY ends, so a pane still mounted for it is a ghost: it renders nothing - * and, once it is the only pane left, absorbs the tab's next close. An empty - * layout plans nothing — absence of a tree is not evidence about any pane. + * Mounted leaves the layout no longer names: main closed, moved or retired them, so a pane still + * mounted for one is a ghost. A pane this window made that main has not named yet is pending, not + * gone. An empty layout plans nothing — absence of a tree is not evidence about any pane. */ export function planTerminalLiveLayoutRemovals( root: TerminalPaneLayoutNode | null | undefined, currentLeafIds: Iterable, - retiredLeafIds: ReadonlySet + pendingLeafIds: ReadonlySet ): string[] { if (!root) { return [] } const layoutLeafIds = new Set(collectLeafIds(root)) - // Why: a mounted leaf the layout stopped naming is a removal only once the - // host is known to have retired it (trackRetiredLeafIds). A snapshot landing - // while the client is still starting a pane must not read as a retirement. return [...currentLeafIds].filter( - (leafId) => !layoutLeafIds.has(leafId) && retiredLeafIds.has(leafId) + (leafId) => !layoutLeafIds.has(leafId) && !pendingLeafIds.has(leafId) ) } @@ -181,51 +160,75 @@ export function planTerminalLiveLayoutInsertions( return insertions } -/** Panes to close for leaves the host retired. Only a pane whose transport has - * no PTY any more is a ghost; a pane with no transport yet, or still bound to - * a PTY, may simply not be named by a stale snapshot. The last pane on the tab - * is never removed. */ -export function selectRetiredPaneIds( - retiredLeafIds: readonly string[], - view: { - paneCount: number - paneIdForLeaf: (leafId: string) => number | null - ptyIdForPane: (paneId: number) => string | null | undefined - } -): number[] { - const paneIds: number[] = [] - for (const leafId of retiredLeafIds) { - if (view.paneCount - paneIds.length <= 1) { - break - } - const paneId = view.paneIdForLeaf(leafId) - if (paneId === null || view.ptyIdForPane(paneId) !== null) { - continue - } - paneIds.push(paneId) +/** This tab's panes the window added or closed ahead of main's layout. */ +export type TerminalPendingLeaves = { added: ReadonlySet; removed: ReadonlySet } + +function withoutLeaves( + root: TerminalPaneLayoutNode, + leafIds: ReadonlySet +): TerminalPaneLayoutNode | null { + let next: TerminalPaneLayoutNode | null = root + for (const leafId of leafIds) { + next = next && removeLeafFromTree(next, leafId).node } - return paneIds + return next } /** - * Leaves the host dropped from its layout whose panes are still mounted. Only a - * leaf the host named before can be retired: a leaf it has never named belongs - * to a pane the client is still starting. A retired leaf stays retired until - * its pane is gone or the host names it again, so a removal skipped while the - * transport still held its PTY is planned again once that PTY clears. + * Makes the mounted panes follow the store's layout: removed leaves detach (no kill; the PTY may + * live on in another tab), then missing leaves mount and attach or spawn, then geometry applies + * in place. Pending leaves win over the layout until main has them. It writes no layout back: the + * store already holds this one. Returns whether panes were added or removed. */ -export function trackRetiredLeafIds(args: { - retiredLeafIds: ReadonlySet - previousLayoutLeafIds: ReadonlySet - layoutLeafIds: ReadonlySet - mountedLeafIds: Iterable -}): ReadonlySet { - const mounted = new Set(args.mountedLeafIds) - const next = new Set() - for (const leafId of [...args.retiredLeafIds, ...args.previousLayoutLeafIds]) { - if (mounted.has(leafId) && !args.layoutLeafIds.has(leafId)) { - next.add(leafId) +export function reconcileMountedTerminalLayout( + manager: Pick< + PaneManager, + | 'getPanes' + | 'getNumericIdForLeaf' + | 'splitPaneAroundLeafIds' + | 'detachPaneForExternalMove' + | 'applyLayoutGeometry' + >, + layout: Pick & { root: TerminalPaneLayoutNode }, + pending: TerminalPendingLeaves +): boolean { + const root = withoutLeaves(layout.root, pending.removed) + if (!root) { + return false + } + const mountedLeafIds = (): string[] => manager.getPanes().map((pane) => pane.leafId) + // Removals first, so insertions anchor on the panes that stay. + const detached = planTerminalLiveLayoutRemovals(root, mountedLeafIds(), pending.added).filter( + (leafId) => { + const paneId = manager.getNumericIdForLeaf(leafId) + return paneId !== null && manager.detachPaneForExternalMove(paneId) } + ) + let inserted = false + for (const insertion of planTerminalLiveLayoutInsertions(root, mountedLeafIds())) { + const sourcePaneId = manager.getNumericIdForLeaf(insertion.sourceLeafId) + if (sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId) !== null) { + continue + } + // An unbound leaf spawns through the pane's normal mount, like a local split. + const ptyId = layout.ptyIdsByLeafId?.[insertion.newLeafId] + const ratio = + insertion.ratio === undefined || insertion.placement === 'after' + ? insertion.ratio + : 1 - insertion.ratio + const created = manager.splitPaneAroundLeafIds( + insertion.sourceLeafIds, + sourcePaneId, + insertion.direction, + { + ...(ratio !== undefined && { ratio }), + ...(ptyId && { ptyId }), + leafId: insertion.newLeafId, + placement: insertion.placement + } + ) + inserted ||= created !== null } - return next + manager.applyLayoutGeometry(root) + return detached.length > 0 || inserted } diff --git a/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts new file mode 100644 index 000000000000..043a384c7b05 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts @@ -0,0 +1,258 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { PaneManagerOptions } from '@/lib/pane-manager/pane-manager-types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { PtyTransport } from './pty-transport-types' +import { serializePaneTree } from './layout-serialization' +import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' + +// Why: happy-dom has no canvas, so xterm cannot open; reconciliation never touches the terminal. +vi.mock('@/lib/pane-manager/pane-lifecycle', async (importOriginal) => { + const actual: object = await importOriginal() + return { ...actual, openTerminal: vi.fn() } +}) + +const { PaneManager } = await import('@/lib/pane-manager/pane-manager') +const { reconcileMountedTerminalLayout } = await import('./terminal-live-layout-reconciliation') + +const A = '11111111-1111-4111-8111-111111111111' +const B = '22222222-2222-4222-8222-222222222222' +const NEW = '33333333-3333-4333-8333-333333333333' + +const leaf = (leafId: string): TerminalPaneLayoutNode => ({ type: 'leaf', leafId }) +const split = ( + first: TerminalPaneLayoutNode, + second: TerminalPaneLayoutNode, + ratio?: number +): TerminalPaneLayoutNode => ({ + type: 'split', + direction: 'vertical', + first, + second, + ...(ratio !== undefined ? { ratio } : {}) +}) + +type Mounted = { + manager: InstanceType + root: HTMLDivElement + onLayoutChanged: Mock> + onPaneCreated: Mock> + onPaneClosed: Mock> +} + +const mounted: Mounted[] = [] + +function mount( + leafIds: string[], + wiring: Partial> = {} +): Mounted { + const root = document.createElement('div') + document.body.appendChild(root) + const onLayoutChanged = vi.fn() + const onPaneCreated = vi.fn(wiring.onPaneCreated) + const onPaneClosed = vi.fn(wiring.onPaneClosed) + const manager = new PaneManager(root, { + linkOpenHint: () => '', + onLayoutChanged, + onPaneCreated, + onPaneClosed + }) + const [first, ...rest] = leafIds + let previous = manager.createInitialPane({ leafId: first }) + for (const leafId of rest) { + previous = manager.splitPane(previous.id, 'vertical', { leafId })! + } + onLayoutChanged.mockClear() + onPaneCreated.mockClear() + onPaneClosed.mockClear() + const view = { manager, root, onLayoutChanged, onPaneCreated, onPaneClosed } + mounted.push(view) + return view +} + +function reconcile( + view: Mounted, + root: TerminalPaneLayoutNode, + options: { ptyIdsByLeafId?: Record; added?: string[]; removed?: string[] } = {} +): boolean { + return reconcileMountedTerminalLayout( + view.manager, + { root, ptyIdsByLeafId: options.ptyIdsByLeafId ?? {} }, + { added: new Set(options.added ?? []), removed: new Set(options.removed ?? []) } + ) +} + +const tree = (view: Mounted): TerminalPaneLayoutNode | null => + view.root.firstElementChild instanceof HTMLElement + ? serializePaneTree(view.root.firstElementChild) + : null + +beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', () => 1) + vi.stubGlobal('cancelAnimationFrame', () => {}) +}) + +afterEach(() => { + for (const view of mounted.splice(0)) { + view.manager.destroy() + view.root.remove() + } + vi.unstubAllGlobals() +}) + +describe('reconcileMountedTerminalLayout', () => { + it('spawns an inserted unbound leaf once, through the normal split placement', () => { + const view = mount([A]) + const layout = split(leaf(A), leaf(NEW)) + + expect(reconcile(view, layout)).toBe(true) + expect(reconcile(view, layout)).toBe(false) + + expect(view.onPaneCreated).toHaveBeenCalledTimes(1) + const [pane, hints] = view.onPaneCreated.mock.calls[0] + expect(pane.leafId).toBe(NEW) + expect(hints).not.toHaveProperty('ptyId') + expect(hints?.placement).toMatchObject({ kind: 'split', parentLeafId: A }) + expect(tree(view)).toEqual(layout) + }) + + it('attaches an inserted bound leaf to its PTY', () => { + const view = mount([A]) + reconcile(view, split(leaf(A), leaf(NEW)), { ptyIdsByLeafId: { [NEW]: 'pty-new' } }) + expect(view.onPaneCreated.mock.calls[0]?.[1]).toMatchObject({ ptyId: 'pty-new' }) + }) + + it('persists nothing itself: only the split reports its own layout change', () => { + const view = mount([A]) + reconcile(view, split(leaf(A), leaf(NEW), 0.3)) + expect(view.onLayoutChanged).toHaveBeenCalledTimes(1) + expect(view.onLayoutChanged).toHaveBeenCalledWith() + }) + + it('detaches a removed leaf instead of closing it', () => { + const view = mount([A, B]) + expect(reconcile(view, leaf(A))).toBe(true) + expect(view.onPaneClosed).toHaveBeenCalledTimes(1) + expect(view.onPaneClosed.mock.calls[0]?.[1]).toMatchObject({ leafId: B, reason: 'detach' }) + expect(tree(view)).toEqual(leaf(A)) + }) + + it('keeps a pending pane main has not named yet', () => { + const view = mount([A, B]) + expect(reconcile(view, leaf(A), { added: [B] })).toBe(false) + expect(view.onPaneClosed).not.toHaveBeenCalled() + expect(view.manager.getPanes().map((pane) => pane.leafId)).toEqual([A, B]) + }) + + it('does not bring back a pane closed here that an older layout still names', () => { + const view = mount([A]) + expect(reconcile(view, split(leaf(A), leaf(B)), { removed: [B] })).toBe(false) + expect(view.onPaneCreated).not.toHaveBeenCalled() + expect(tree(view)).toEqual(leaf(A)) + }) + + it('removes before inserting, so the new leaf anchors on a pane that stays', () => { + const view = mount([A, B]) + const layout = split(leaf(A), leaf(NEW)) + reconcile(view, layout) + expect(tree(view)).toEqual(layout) + }) + + it('applies a ratio change in place without remounting', () => { + const view = mount([A, B]) + const before = view.manager.getPanes().map((pane) => [pane.id, pane.container]) + expect(reconcile(view, split(leaf(A), leaf(B), 0.25))).toBe(false) + expect(tree(view)).toEqual(split(leaf(A), leaf(B), 0.25)) + expect(view.manager.getPanes().map((pane) => [pane.id, pane.container])).toEqual(before) + expect(view.onPaneCreated).not.toHaveBeenCalled() + expect(view.onLayoutChanged).not.toHaveBeenCalled() + }) + + it('rebuilds nothing when main publishes the tree a local split proposed', () => { + const view = mount([A, B]) + view.manager.splitPane(view.manager.getNumericIdForLeaf(B)!, 'horizontal', { leafId: NEW }) + // Main keeps the split's proposedRoot verbatim, so its push names the DOM's own tree. + const published = tree(view)! + const panes = view.manager.getPanes().map((pane) => [pane.id, pane.container]) + const observer = new MutationObserver(() => {}) + observer.observe(view.root, { attributes: true, childList: true, subtree: true }) + + const ptyIdsByLeafId = { [A]: 'pty-a', [B]: 'pty-b', [NEW]: 'pty-new' } + expect(reconcile(view, published, { ptyIdsByLeafId })).toBe(false) + + expect(observer.takeRecords()).toEqual([]) + observer.disconnect() + expect(view.manager.getPanes().map((pane) => [pane.id, pane.container])).toEqual(panes) + }) + + it('leaves a divider the user is dragging alone', () => { + const view = mount([A, B]) + view.root.querySelector('.pane-divider')?.classList.add('is-dragging') + reconcile(view, split(leaf(A), leaf(B), 0.25)) + expect(tree(view)).toEqual(split(leaf(A), leaf(B))) + }) +}) + +describe('a leaf main moved to another tab', () => { + const originalWindow = globalThis.window + let deliver: ((payload: { id: string; data: string }) => void) | null = null + + beforeEach(() => { + vi.resetModules() + deliver = null + installIpcPtyWindow(originalWindow, { + data: (callback) => { + deliver = callback + } + }) + }) + + afterEach(() => { + restorePtySpecWindow(originalWindow) + }) + + it.each(['source first', 'destination first'] as const)( + 'is never killed and has one output owner (%s)', + async (order) => { + const { createIpcPtyTransport } = await import('./pty-transport') + const received: string[] = [] + // TerminalPane's wiring: a hinted PTY attaches, and a detached pane releases without a kill. + const wiring = ( + tab: string, + transports = new Map() + ): Parameters[1] => ({ + onPaneCreated: (pane, hints) => { + const transport = createIpcPtyTransport({}) + transport.attach({ + existingPtyId: hints?.ptyId ?? `pty-${pane.leafId}`, + callbacks: { onData: (data) => received.push(`${tab}:${data}`) } + }) + transports.set(pane.id, transport) + }, + onPaneClosed: (paneId, closed) => { + const transport = transports.get(paneId) + return closed?.reason === 'detach' + ? transport?.detach?.({ preserveExitObserver: false }) + : transport?.destroy?.() + } + }) + const source = mount([A, B], wiring('source')) + const destination = mount([A], wiring('destination')) + const moveOut = (): boolean => reconcile(source, leaf(A)) + const moveIn = (): boolean => + reconcile(destination, split(leaf(A), leaf(B)), { ptyIdsByLeafId: { [B]: `pty-${B}` } }) + + if (order === 'source first') { + moveOut() + moveIn() + } else { + moveIn() + moveOut() + } + deliver?.({ id: `pty-${B}`, data: 'live' }) + + expect(window.api.pty.kill).not.toHaveBeenCalled() + expect(received).toEqual(['destination:live']) + } + ) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-binding-ownership.test.tsx b/src/renderer/src/components/terminal-pane/terminal-pane-binding-ownership.test.tsx new file mode 100644 index 000000000000..dcb5188184b9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-binding-ownership.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { useTerminalPaneLayoutBindings } from './use-terminal-pane-layout-bindings' +import type { TerminalPaneLayoutController } from './use-terminal-pane-layout-persistence' + +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' + +const store = vi.hoisted((): { terminalLayoutsByTabId: Record } => ({ + terminalLayoutsByTabId: {} +})) +vi.mock('../../store', () => ({ useAppStore: { getState: () => store } })) + +function splitBoundTo(left: string, right: string): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + }, + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: left, [RIGHT]: right } + } +} + +function renderBindings(layout: TerminalLayoutSnapshot) { + store.terminalLayoutsByTabId = { tab: layout } + const setTabLayout = vi.fn() + const controller = { + containerRef: { current: null }, + expandedPaneIdRef: { current: null }, + expandedStyleSnapshotRef: { current: new Map() }, + managerRef: { current: { getLeafId: (paneId: number) => (paneId === 1 ? LEFT : RIGHT) } }, + paneTransportsRef: { current: new Map() }, + pendingPaneSizeRefreshFrameIdsRef: { current: [] }, + persistLayoutSnapshot: vi.fn(), + setExpandedPaneId: vi.fn(), + setTabLayout, + setTabPaneExpanded: vi.fn(), + tabId: 'tab' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding writers read only these controller fields. + const typed = controller as unknown as TerminalPaneLayoutController + const { result } = renderHook(() => useTerminalPaneLayoutBindings(typed)) + return { bindings: result.current, setTabLayout } +} + +afterEach(cleanup) + +// R17: main keeps an exited pane's binding, so its remount does one stale reattach, then spawns. +describe("a pane's PTY binding", () => { + it('is never written by the window for a local or SSH pane, on spawn or on exit', () => { + const { bindings, setTabLayout } = renderBindings(splitBoundTo('pty-left', 'ssh:t@@pty-right')) + + bindings.syncPanePtyLayoutBinding(1, 'pty-fresh') + bindings.syncPanePtyLayoutBinding(2, null) + bindings.clearExitedPanePtyLayoutBinding(1, 'pty-left') + bindings.clearExitedPanePtyLayoutBindingForLeaf(RIGHT, 'ssh:t@@pty-right') + + expect(setTabLayout).not.toHaveBeenCalled() + }) + + it("is still written by the window for a remote runtime's pane, for the push to its host", () => { + const { bindings, setTabLayout } = renderBindings( + splitBoundTo('remote:env@@pty-left', 'remote:env@@pty-right') + ) + + bindings.clearExitedPanePtyLayoutBinding(1, 'remote:env@@pty-left') + + expect(setTabLayout).toHaveBeenCalledExactlyOnceWith( + 'tab', + expect.objectContaining({ ptyIdsByLeafId: { [RIGHT]: 'remote:env@@pty-right' } }) + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts index 4e0d7bd960cd..4b264e232e8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts @@ -1,6 +1,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import type { PaneSpawnHints } from '@/lib/pane-manager/pane-manager-types' import { useAppStore } from '@/store' +import { commitTerminalPaneIfAheadOfMain } from '@/store/terminals/terminal-pending-panes' import { makePaneKey } from '../../../../shared/stable-pane-id' import { createOsc52OscHandler } from './osc52-clipboard' import { @@ -49,6 +50,21 @@ export function createTerminalPaneCreatedHandler( deps const { deferredSplitHandoffs } = context const paneKey = makePaneKey(deps.tabId, pane.leafId) + const placement = + spawnHints?.placement && + completePaneSpawnPlacement(spawnHints.placement, { + worktreeId: deps.worktreeId, + tabId: deps.tabId, + container: deps.containerRef.current + }) + if (placement) { + commitTerminalPaneIfAheadOfMain(useAppStore.getState(), { + worktreeId: deps.worktreeId, + tabId: deps.tabId, + leafId: pane.leafId, + placement + }) + } const restoredPtyId = ptyDeps.restoredPtyIdByLeafId?.[pane.leafId] const hasAuthoritativeSpawnHint = Boolean(spawnHints?.cwd || spawnHints?.ptyId || restoredPtyId) let effectiveSpawnHints = spawnHints @@ -203,15 +219,7 @@ export function createTerminalPaneCreatedHandler( : {}), ...(effectiveSpawnHints?.cwd ? { cwd: effectiveSpawnHints.cwd } : {}), ...(effectiveSpawnHints?.cwdPromise ? { cwdPromise: effectiveSpawnHints.cwdPromise } : {}), - ...(effectiveSpawnHints?.placement - ? { - placement: completePaneSpawnPlacement(effectiveSpawnHints.placement, { - worktreeId: deps.worktreeId, - tabId: deps.tabId, - container: deps.containerRef.current - }) - } - : {}), + ...(placement ? { placement } : {}), restoredPtyIdByLeafId: effectiveSpawnHints?.ptyId ? { ...ptyDeps.restoredPtyIdByLeafId, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts index eec7df33470b..925b037e28ae 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts @@ -13,6 +13,7 @@ const TAB = { sortOrder: 1, createdAt: 42, shellOverride: 'wsl.exe', + forceHostRuntime: true, isPinned: true } @@ -52,7 +53,8 @@ describe('completePaneSpawnPlacement', () => { customTitle: 'Build', color: '#f97316', createdAt: 42, - shellOverride: 'wsl.exe' + shellOverride: 'wsl.exe', + forceHostRuntime: true } }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts index 8b83383572d5..7a1aa148a46e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts @@ -1,36 +1,34 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../../../shared/terminal-leaf-move' +import type { + TerminalTopologyReply, + TerminalTopologySlice +} from '../../../../shared/terminal-topology-slice' import { detachTerminalPaneToTab } from './terminal-pane-tab-detach' import { - createStore, + createTerminalTab, LEAF_1, LEAF_2, SOURCE_TAB_ID, splitLayout, TARGET_GROUP_ID, - unboundSplitLayout, WORKTREE_ID } from './terminal-pane-tab-detach-fixture' const toastErrorMock = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) -const closeTerminalSurface = vi.fn(async () => {}) -beforeEach(() => { - toastErrorMock.mockClear() - closeTerminalSurface.mockClear() - vi.spyOn(console, 'warn').mockImplementation(() => {}) -}) -/** Answers each call with the next result; an Error throws. */ -function mainAnswering(results: (TerminalLeafMoveResult | Error)[]) { - return vi.fn((_request: TerminalLeafMoveRequest): Promise => { - const next = results.shift() ?? { status: 'not_held' } - return next instanceof Error ? Promise.reject(next) : Promise.resolve(next) - }) -} +const OTHER_GROUP_ID = 'group-other' +const PTY = 'pty-right' +const initial = useAppStore.getState() +const state = () => useAppStore.getState() +const closeTerminalSurface = vi.fn(async (): Promise => ({})) + +type MoveAnswer = TerminalLeafMoveResult & TerminalTopologyReply function managerWithPanes(paneIds: () => number[] = () => [1, 2]) { return { @@ -40,233 +38,218 @@ function managerWithPanes(paneIds: () => number[] = () => [1, 2]) { } } -type CommitMove = (request: TerminalLeafMoveRequest) => Promise +/** Main's slice after the move: the source keeps LEAF_1, the new tab holds LEAF_2. */ +function movedSlice(publishSeq: number, targetTabId: string): TerminalTopologySlice { + return { + hostId: 'local', + worktreeId: WORKTREE_ID, + publishSeq, + revision: 2, + tabs: [ + { id: SOURCE_TAB_ID, ptyId: 'pty-left', worktreeId: WORKTREE_ID, createdAt: 1 }, + { id: targetTabId, ptyId: PTY, worktreeId: WORKTREE_ID, createdAt: 2 } + ], + presentation: {}, + layouts: { + [SOURCE_TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_1 }, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }, + [targetTabId]: { + root: { type: 'leaf', leafId: LEAF_2 }, + ptyIdsByLeafId: { [LEAF_2]: PTY } + } + }, + sleeping: {} + } +} + +function seed(): void { + const layout = splitLayout() + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [createTerminalTab(SOURCE_TAB_ID, 'pty-left')] }, + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: { ...layout, ptyIdsByLeafId: { [LEAF_1]: 'pty-left', [LEAF_2]: PTY } } + }, + ptyIdsByTabId: { [SOURCE_TAB_ID]: ['pty-left', PTY] }, + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + { + id: SOURCE_TAB_ID, + entityId: SOURCE_TAB_ID, + groupId: OTHER_GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal 2', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: OTHER_GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: SOURCE_TAB_ID, + tabOrder: [SOURCE_TAB_ID] + }, + { id: TARGET_GROUP_ID, worktreeId: WORKTREE_ID, activeTabId: null, tabOrder: [] } + ] + }, + activeGroupIdByWorktree: { [WORKTREE_ID]: OTHER_GROUP_ID }, + activeWorktreeId: WORKTREE_ID, + terminalTopologySeqByWorktree: { [WORKTREE_ID]: 1 } + }) +} function detach( - overrides: Partial[0]> & { - commitMove?: CommitMove - store?: ReturnType - } + answer: (request: TerminalLeafMoveRequest) => Promise, + manager = managerWithPanes() ) { - const { commitMove = mainAnswering([]), store = createStore(), ...rest } = overrides - vi.stubGlobal('window', { - api: { pty: { moveLeafToNewTab: commitMove }, session: { closeTerminalSurface } } - }) - return detachTerminalPaneToTab({ - getStore: () => store, - manager: managerWithPanes(), + const moveLeafToNewTab = vi.fn(answer) + vi.stubGlobal('window', { api: { pty: { moveLeafToNewTab }, session: { closeTerminalSurface } } }) + const result = detachTerminalPaneToTab({ + getStore: state, + subscribe: useAppStore.subscribe, + manager, persistLayoutSnapshot: vi.fn(), sourcePaneId: 2, sourceTabId: SOURCE_TAB_ID, targetGroupId: TARGET_GROUP_ID, - worktreeId: WORKTREE_ID, - ...rest + worktreeId: WORKTREE_ID }) + return { result, moveLeafToNewTab } } -const moved: TerminalLeafMoveResult = { status: 'moved', ptyId: 'remote:env-1@@terminal-1' } - -describe('detachTerminalPaneToTab rolls a committed move forward', () => { - it('refuses, silently, a pane whose PTY spawn is still in flight', async () => { - const commitMove = mainAnswering([{ status: 'moved', ptyId: null }]) - const store = createStore(unboundSplitLayout()) - - await expect(detach({ commitMove, store, sourceConnectPending: true })).resolves.toBeNull() - - expect(commitMove).not.toHaveBeenCalled() - expect(store.createTab).not.toHaveBeenCalled() - expect(toastErrorMock).not.toHaveBeenCalled() - }) - - it('shows the failure toast and keeps the pane when main refuses or the commit throws', async () => { - for (const results of [ - [{ status: 'refused', reason: 'pty_mismatch' } as const], - [new Error('write failed')] - ]) { - toastErrorMock.mockClear() - const store = createStore() - const manager = managerWithPanes() - - await expect( - detach({ commitMove: mainAnswering(results), store, manager }) - ).resolves.toBeNull() - - expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled() - expect(store.createTab).not.toHaveBeenCalled() - expect(toastErrorMock).toHaveBeenCalledOnce() - } - }) +const targetTabIdOf = (move: { mock: { calls: [TerminalLeafMoveRequest][] } }): string => + move.mock.calls[0]?.[0].targetTabId ?? '' + +function expectTargetTabShownOnce(targetTabId: string): void { + const terminalIds = state().tabsByWorktree[WORKTREE_ID].map((tab) => tab.id) + expect(terminalIds).toEqual([SOURCE_TAB_ID, targetTabId]) + const target = state().groupsByWorktree[WORKTREE_ID].find((g) => g.id === TARGET_GROUP_ID) + expect(target?.tabOrder).toEqual([targetTabId]) + expect(state().activeTabId).toBe(targetTabId) + expect(state().ptyIdsByTabId[SOURCE_TAB_ID]).toEqual(['pty-left']) + expect(state().ptyIdsByTabId[targetTabId]).toEqual([PTY]) + expect(closeTerminalSurface).not.toHaveBeenCalled() +} - it('closes main’s new tab, without a toast, when the user closed the pane meanwhile', async () => { - let paneIds = [1, 2] - const manager = managerWithPanes(() => paneIds) - const commitMove = vi.fn( - async (_request: TerminalLeafMoveRequest): Promise => { - paneIds = [1] - manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : null)) - return moved - } - ) - const store = createStore() +beforeEach(() => { + toastErrorMock.mockClear() + closeTerminalSurface.mockReset() + closeTerminalSurface.mockResolvedValue({}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + seed() +}) - await expect(detach({ commitMove, manager, store })).resolves.toBeNull() +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + useAppStore.setState(initial, true) +}) - const targetTabId = commitMove.mock.calls[0]?.[0]?.targetTabId - expect(closeTerminalSurface).toHaveBeenCalledWith({ - worktreeId: WORKTREE_ID, - target: { kind: 'tab', tabId: targetTabId }, - reason: 'cleanup' +describe('dragging a pane out to a new tab follows main’s move', () => { + it('shows the new tab once when main’s push arrives before the reply', async () => { + const { result, moveLeafToNewTab } = detach(async (request) => { + // The reconciler detaches the source pane as soon as this push lands. + state().applyTerminalTopologySlices([movedSlice(2, request.targetTabId)]) + return { status: 'moved', ptyId: PTY, publishSeq: 2 } }) - expect(store.createTab).not.toHaveBeenCalled() - expect(toastErrorMock).not.toHaveBeenCalled() - }) - it('closes main’s new tab when the user closed the source tab meanwhile', async () => { - const store = createStore() - const commitMove = vi.fn( - async (_request: TerminalLeafMoveRequest): Promise => { - // A tab close drops its row and its layout. - store.tabsByWorktree[WORKTREE_ID] = [] - delete store.terminalLayoutsByTabId[SOURCE_TAB_ID] - return moved - } - ) - - await expect(detach({ commitMove, store })).resolves.toBeNull() - - expect(closeTerminalSurface).toHaveBeenCalledOnce() - expect(store.createTab).not.toHaveBeenCalled() + await expect(result).resolves.toMatchObject({ leafId: LEAF_2, ptyId: PTY }) + expect(moveLeafToNewTab).toHaveBeenCalledOnce() + expectTargetTabShownOnce(targetTabIdOf(moveLeafToNewTab)) }) - it('finds the pane by its leaf when its pane id changed meanwhile', async () => { - const manager = managerWithPanes(() => [1, 7]) - manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : LEAF_2)) - const commitMove = vi.fn( - async (_request: TerminalLeafMoveRequest): Promise => { - manager.getLeafId.mockImplementation((paneId) => - paneId === 1 ? LEAF_1 : paneId === 7 ? LEAF_2 : null - ) - return moved - } - ) + it('waits for main’s push when the reply arrives first', async () => { + const { result, moveLeafToNewTab } = detach(async () => ({ + status: 'moved', + ptyId: PTY, + publishSeq: 2 + })) + await vi.waitFor(() => expect(moveLeafToNewTab).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(state().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) - await expect(detach({ commitMove, manager })).resolves.toMatchObject({ leafId: LEAF_2 }) + state().applyTerminalTopologySlices([movedSlice(2, targetTabIdOf(moveLeafToNewTab))]) - expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(7) + await expect(result).resolves.toMatchObject({ leafId: LEAF_2 }) + expectTargetTabShownOnce(targetTabIdOf(moveLeafToNewTab)) }) - it('closes main’s new tab when the pane manager refuses to detach the pane', async () => { - const manager = managerWithPanes() - manager.detachPaneForExternalMove.mockReturnValue(false) - const commitMove = mainAnswering([moved]) - const store = createStore() - - await expect(detach({ commitMove, manager, store })).resolves.toBeNull() + it('closes main’s new tab when the user closed the pane meanwhile', async () => { + closeTerminalSurface.mockResolvedValue({ publishSeq: 3 }) + const { result, moveLeafToNewTab } = detach(async () => { + state().markPendingTerminalPane({ + worktreeId: WORKTREE_ID, + tabId: SOURCE_TAB_ID, + leafId: LEAF_2, + change: 'remove' + }) + return { status: 'moved', ptyId: PTY, publishSeq: 2 } + }) + await expect(result).resolves.toBeNull() + const targetTabId = targetTabIdOf(moveLeafToNewTab) expect(closeTerminalSurface).toHaveBeenCalledWith({ worktreeId: WORKTREE_ID, - target: { kind: 'tab', tabId: commitMove.mock.calls[0]?.[0]?.targetTabId }, + target: { kind: 'tab', tabId: targetTabId }, reason: 'cleanup' }) - expect(store.createTab).not.toHaveBeenCalled() - expect(store.setTabLayout).not.toHaveBeenCalled() + // Main's push with the new tab can't show it meanwhile. + state().applyTerminalTopologySlices([movedSlice(2, targetTabId)]) + expect(state().tabsByWorktree[WORKTREE_ID].map((tab) => tab.id)).toEqual([SOURCE_TAB_ID]) }) - it('moves the last pane, after a sibling closed meanwhile, without killing its PTY', async () => { - const store = createStore() + it('closes the source tab, keeping the PTY, when a sibling closed meanwhile', async () => { + const closeTab = vi.spyOn(state(), 'closeTab') let paneIds = [1, 2] - const manager = managerWithPanes(() => paneIds) - const commitMove = vi.fn(async (_request: TerminalLeafMoveRequest) => { - // Closing a pane persists the layout synchronously (onLayoutChanged), as the real close does. - paneIds = [2] - store.terminalLayoutsByTabId[SOURCE_TAB_ID] = { - root: { type: 'leaf', leafId: LEAF_2 }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_2]: 'pty-right' } - } - return { status: 'moved', ptyId: 'pty-right' } satisfies TerminalLeafMoveResult - }) - - await expect(detach({ commitMove, manager, store })).resolves.toMatchObject({ - leafId: LEAF_2, - ptyId: 'pty-right' - }) - - expect(closeTerminalSurface).not.toHaveBeenCalled() - expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled() - expect(store.createTab).toHaveBeenCalledOnce() - expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith( - expect.objectContaining({ detachedLeafId: LEAF_2, detachedPtyId: 'pty-right' }) + const { result } = detach( + async (request) => { + paneIds = [2] + state().applyTerminalTopologySlices([movedSlice(2, request.targetTabId)]) + return { status: 'moved', ptyId: PTY, publishSeq: 2 } + }, + managerWithPanes(() => paneIds) ) - expect(store.closeTab).toHaveBeenCalledWith( + + await expect(result).resolves.toMatchObject({ leafId: LEAF_2 }) + expect(closeTab).toHaveBeenCalledWith( SOURCE_TAB_ID, expect.objectContaining({ localPtyTeardownOwnedExternally: true }) ) }) - it('commits the move in main before the target tab exists, using the same tab id', async () => { - const store = createStore() - let release!: () => void - const commitMove = vi.fn( - (_request: TerminalLeafMoveRequest) => - new Promise((resolve) => { - release = () => resolve(moved) - }) - ) - - const detaching = detach({ commitMove, store }) - await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce()) - expect(store.createTab).not.toHaveBeenCalled() - release() - await detaching - - const request = commitMove.mock.calls[0]?.[0] - expect(request).toEqual({ - worktreeId: WORKTREE_ID, - sourceTabId: SOURCE_TAB_ID, - targetTabId: expect.any(String), - leafId: LEAF_2, - ptyId: expect.any(String) - }) - expect(store.createTab).toHaveBeenCalledWith( - WORKTREE_ID, - TARGET_GROUP_ID, - 'powershell.exe', - expect.objectContaining({ id: request?.targetTabId, initialLeafId: LEAF_2 }) - ) + it('shows the failure toast and keeps the pane when main refuses or the commit throws', async () => { + for (const answer of [ + async (): Promise => ({ status: 'refused', reason: 'pty_mismatch' }), + async (): Promise => { + throw new Error('write failed') + } + ]) { + toastErrorMock.mockClear() + const manager = managerWithPanes() + await expect(detach(answer, manager).result).resolves.toBeNull() + expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled() + expect(state().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(toastErrorMock).toHaveBeenCalledOnce() + } }) it('ignores a repeat drop of a pane whose move is still committing', async () => { - const store = createStore() - let release!: () => void - const commitMove = vi.fn( - (_request: TerminalLeafMoveRequest) => - new Promise((resolve) => { - release = () => resolve(moved) - }) - ) - - const first = detach({ commitMove, store }) - await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce()) - await expect(detach({ commitMove, store })).resolves.toBeNull() - release() - - await expect(first).resolves.toMatchObject({ leafId: LEAF_2 }) - expect(commitMove).toHaveBeenCalledOnce() - expect(toastErrorMock).not.toHaveBeenCalled() - }) - - it('binds the moved tab’s layout to the live PTY, not the saved one', async () => { - const store = createStore(splitLayout()) - const saved = store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.ptyIdsByLeafId?.[LEAF_2] - expect(saved).toBeTruthy() - - const result = await detach({ livePtyId: 'pty-respawned', store }) - - expect(result?.ptyId).toBe('pty-respawned') - expect(store.terminalLayoutsByTabId['tab-detached']?.ptyIdsByLeafId?.[LEAF_2]).toBe( - 'pty-respawned' - ) + let release!: (answer: MoveAnswer) => void + const first = detach(() => new Promise((resolve) => (release = resolve))) + await vi.waitFor(() => expect(first.moveLeafToNewTab).toHaveBeenCalledOnce()) + const repeat = detach(async () => ({ status: 'moved', ptyId: PTY })) + + await expect(repeat.result).resolves.toBeNull() + expect(repeat.moveLeafToNewTab).not.toHaveBeenCalled() + state().applyTerminalTopologySlices([movedSlice(2, targetTabIdOf(first.moveLeafToNewTab))]) + release({ status: 'moved', ptyId: PTY, publishSeq: 2 }) + await expect(first.result).resolves.toMatchObject({ leafId: LEAF_2 }) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts index 75ef69692e80..aac787546984 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -51,6 +51,7 @@ async function expectDeferredSplitDetachRejected(sourcePaneCwd: SourcePaneCwd): const result = await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot, sourcePaneCwd, @@ -181,6 +182,7 @@ describe('detachTerminalPaneToTab', () => { const store = createStore({ ...splitLayout(), chatLeafId }) await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager: { getPanes: () => [{ id: 1 }, { id: 2 }], getLeafId: () => LEAF_2, @@ -214,6 +216,7 @@ describe('detachTerminalPaneToTab', () => { const result = await detachTerminalPaneToTab({ manager, getStore: () => store, + subscribe: () => () => {}, persistLayoutSnapshot, sourcePaneCwd: { cwd: '/remote/repo', @@ -279,6 +282,7 @@ describe('detachTerminalPaneToTab', () => { await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneId: 1, @@ -306,6 +310,7 @@ describe('detachTerminalPaneToTab', () => { const result = await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneId: 1, @@ -349,6 +354,7 @@ describe('detachTerminalPaneToTab', () => { await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneId: 2, @@ -385,6 +391,7 @@ describe('detachTerminalPaneToTab', () => { await detachTerminalPaneToTab({ livePtyId: 'remote:env-2@@terminal-9', getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneCwd: { @@ -440,6 +447,7 @@ describe('detachTerminalPaneToTab', () => { const result = await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneCwd: { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index b71d435af537..d0e01a5ae7b1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -4,7 +4,9 @@ import { translate } from '@/i18n/i18n' import { createBrowserUuid } from '@/lib/browser-uuid' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TerminalLeafMoveRequest } from '../../../../shared/terminal-leaf-move' +import { emptyLayoutSnapshot } from '@/store/slices/terminal-helpers' import { commitTerminalSurfaceClose } from '@/store/terminals/terminal-surface-close-intent' +import { terminalTopologyApplied } from '@/store/terminals/terminal-topology-mirror' import type { PaneCwdEntry } from './resolve-split-cwd' import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' export { @@ -18,13 +20,19 @@ export type TerminalPaneTabDetachStore = Pick< | 'closeTab' | 'createTab' | 'groupsByWorktree' + | 'markPendingTerminalPane' + | 'moveUnifiedTabToGroup' + | 'pendingTerminalPanes' | 'reorderUnifiedTabs' + | 'restoreTerminalTopologySlice' | 'setActiveTab' | 'setActiveTabType' | 'setTabLayout' | 'syncPaneDetachPtyOwnership' + | 'settlePendingTerminalPane' | 'tabsByWorktree' | 'terminalLayoutsByTabId' + | 'terminalTopologySeqByWorktree' > type TerminalPaneTabDetachManager = { @@ -73,6 +81,7 @@ type DetachTerminalPaneToTabArgs = { /** The PTY the source pane's transport is attached to now; it outranks the saved layout. */ livePtyId?: string | null getStore: () => TerminalPaneTabDetachStore + subscribe: (listener: () => void) => () => void manager: TerminalPaneTabDetachManager | null persistLayoutSnapshot: () => void sourcePaneId: number @@ -89,8 +98,8 @@ type DetachTerminalPaneToTabArgs = { const leavesMovingToNewTab = new Set() /** - * Moves a pane into a new tab. Main commits the move (leaf, binding and pane-keyed records) before - * the target tab exists here, so the moved pane's reattach never races a second owner (STA-9259). + * Moves a pane into a new tab. Main commits the move (leaf, binding and pane-keyed records) and + * publishes the new tab, so the moved pane's reattach never races a second owner (STA-9259). * Once main has moved it, this window only rolls forward: it never asks main to put it back. */ export async function detachTerminalPaneToTab( @@ -144,10 +153,10 @@ async function moveLeafToNewTab( return null }) if (answer?.status === 'moved') { - return applyMove(args, request, true, answer.ptyId) + return followMainMove(args, request, answer.ptyId, answer.publishSeq) } if (answer?.status === 'not_held') { - return applyMove(args, request, false, request.ptyId) + return applyMove(args, request, request.ptyId) } // A failed write rolls main back; one whose outcome is unknown faults persistence, and the next // load converges on whichever side main kept. @@ -156,11 +165,82 @@ async function moveLeafToNewTab( return null } -/** Applies the move here; the pane is found by its leaf, since its pane id may have changed. */ +/** + * Main moved the leaf; its push brings the new tab and drops the leaf from the source, and the + * reconciler detaches the source pane. This window adds only presentation and the live + * attachment, after that push, whichever of the push and the reply arrived first. + */ +async function followMainMove( + args: DetachTerminalPaneToTabArgs, + request: TerminalLeafMoveRequest, + ptyId: string | null, + publishSeq: number | undefined +): Promise { + const { leafId, sourceTabId, targetTabId, worktreeId } = request + const closedMeanwhile = args + .getStore() + .pendingTerminalPanes.some( + (entry) => + entry.change === 'remove' && + entry.tabId === sourceTabId && + (entry.leafId === undefined || entry.leafId === leafId) + ) + if (closedMeanwhile) { + // The user closed the pane or its tab while main moved it, so the new tab holds nothing. + commitTerminalSurfaceClose( + args.getStore(), + worktreeId, + { kind: 'tab', tabId: targetTabId }, + 'cleanup' + ) + return null + } + await terminalTopologyApplied(args.subscribe, args.getStore, worktreeId, publishSeq) + const store = args.getStore() + const tab = store.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === targetTabId) + if (!tab) { + return null + } + store.moveUnifiedTabToGroup(targetTabId, args.targetGroupId) + moveCreatedTabToIndex({ + groupId: args.targetGroupId, + store: args.getStore(), + tabId: targetTabId, + targetIndex: args.targetIndex, + worktreeId + }) + store.syncPaneDetachPtyOwnership({ + detachedLeafId: leafId, + detachedPtyId: ptyId, + sourceLayout: store.terminalLayoutsByTabId[sourceTabId] ?? emptyLayoutSnapshot(), + sourceTabId, + targetTabId + }) + const panes = args.manager?.getPanes() ?? [] + if (panes.length === 1 && args.manager?.getLeafId(panes[0].id) === leafId) { + // A sibling closed meanwhile; the pane manager keeps a last pane, so its tab goes instead. + closeSourceTabKeepingPty(store, sourceTabId) + } + store.setActiveTab(targetTabId) + store.setActiveTabType('terminal', worktreeId) + return { tab, leafId, ptyId } +} + +function closeSourceTabKeepingPty(store: TerminalPaneTabDetachStore, sourceTabId: string): void { + // The new tab reattaches the PTY, so the source tab's close must not kill it. + store.closeTab(sourceTabId, { + reason: 'cleanup', + recordInteraction: false, + captureRecentlyClosed: false, + localPtyTeardownOwnedExternally: true + }) +} + +/** Moves a pane main doesn't hold (a runtime host's tab) here; the pane is found by its leaf, + * since its pane id may have changed. */ function applyMove( args: DetachTerminalPaneToTabArgs, request: TerminalLeafMoveRequest, - mainMoved: boolean, ptyId: string | null ): DetachedTerminalPaneTab | null { const { leafId, sourceTabId, targetTabId, worktreeId } = request @@ -180,11 +260,6 @@ function applyMove( !movedLayout || (!lastPane && !manager.detachPaneForExternalMove(paneId)) ) { - // Why: the pane or its tab is gone here (or would not detach), so the tab main moved it into - // holds nothing; close it the way any tab close reaches main. - if (mainMoved) { - commitTerminalSurfaceClose(worktreeId, { kind: 'tab', tabId: targetTabId }, 'cleanup') - } return null } const detachedLayout = ptyId @@ -228,13 +303,7 @@ function applyMove( targetTabId: tab.id }) if (!split) { - // The new tab reattaches the PTY, so the source tab's close must not kill it. - afterCreateStore.closeTab(sourceTabId, { - reason: 'cleanup', - recordInteraction: false, - captureRecentlyClosed: false, - localPtyTeardownOwnedExternally: true - }) + closeSourceTabKeepingPty(afterCreateStore, sourceTabId) } afterCreateStore.setActiveTab(tab.id) afterCreateStore.setActiveTabType('terminal', worktreeId) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index efad698fca4f..8de030879aa1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -59,7 +59,11 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr // The user closed a launch pane whose agent is still starting; main stops that launch. noteAgentLaunchPaneClosedByUser(tabId, leafId) } - commitTerminalSurfaceClose(worktreeId, { kind: 'pane', tabId, leafId }) + commitTerminalSurfaceClose(useAppStore.getState(), worktreeId, { + kind: 'pane', + tabId, + leafId + }) retireUnboundIpcTerminalPane({ getState: useAppStore.getState, tabId, @@ -245,6 +249,7 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr void detachTerminalPaneToTab({ livePtyId, getStore: useAppStore.getState, + subscribe: useAppStore.subscribe, manager: managerRef.current, persistLayoutSnapshot, sourcePaneId, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts index ebbe137cec42..7bd125026c99 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts @@ -5,6 +5,7 @@ import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { shouldIgnoreStalePanePtyLayoutBinding } from './pty-connection/pane-pty-layout-binding' import { useExpandCollapseActions } from './expand-collapse' import type { TerminalPaneLayoutController } from './use-terminal-pane-layout-persistence' +import { isRemoteRuntimePtyId } from '@/store/terminals/terminal-pty-identities' export function useTerminalPaneLayoutBindings(controller: TerminalPaneLayoutController) { const { @@ -31,6 +32,11 @@ export function useTerminalPaneLayoutBindings(controller: TerminalPaneLayoutCont const { ptyIdsByLeafId: _existingPtyIdsByLeafId, ...layoutWithoutPtyBindings } = existingLayout const existingBindings = existingLayout.ptyIdsByLeafId ?? {} + // A local or SSH pane's binding is main's: recorded on spawn, mirrored here, kept after exit + // (R17). Only a remote runtime's is written here, for the push to its host. + if (!isRemoteRuntimePtyId(ptyId ?? existingBindings[leafId])) { + return + } if (ptyId && sourcePaneId !== undefined) { const currentTransportPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() @@ -107,7 +113,7 @@ export function useTerminalPaneLayoutBindings(controller: TerminalPaneLayoutCont const { ptyIdsByLeafId: _existingPtyIdsByLeafId, ...layoutWithoutPtyBindings } = existingLayout const existingBindings = existingLayout.ptyIdsByLeafId ?? {} - if (existingBindings[leafId] !== exitedPtyId) { + if (existingBindings[leafId] !== exitedPtyId || !isRemoteRuntimePtyId(exitedPtyId)) { return } const nextBindings = { ...existingBindings } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index bbeedc2e524d..e57b46ac5e85 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -4,6 +4,11 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' +import { terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' +import { + commitPendingTerminalChange, + terminalTabPanesNamed +} from '@/store/terminals/terminal-pending-panes' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { clearWebRuntimeTerminalBuffer } from '@/runtime/web-runtime-session' @@ -82,27 +87,19 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup if (Object.keys(mergedScrollbackRefs).length > 0) { layout.scrollbackRefsByLeafId = mergedScrollbackRefs } - const livePtyEntries = currentPanes - .map( - (pane) => - [pane.leafId, paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null] as const - ) - .filter( - (entry): entry is readonly [(typeof currentPanes)[number]['leafId'], string] => - entry[1] !== null - ) - const mergedPtyIds = mergeCapturedLeafState({ + // Bindings are main's (the mirror writes them); this save only drops closed leaves. + const ptyIdsByLeafId = mergeCapturedLeafState({ prior: existing?.ptyIdsByLeafId, - fresh: Object.fromEntries(livePtyEntries), + fresh: {}, currentLeafIds }) - if (Object.keys(mergedPtyIds).length > 0) { - layout.ptyIdsByLeafId = mergedPtyIds + if (Object.keys(ptyIdsByLeafId).length > 0) { + layout.ptyIdsByLeafId = ptyIdsByLeafId } layout.activeLeafId = resolveTerminalLayoutActiveLeafId({ root: layout.root, activeLeafId: layout.activeLeafId, - ptyIdsByLeafId: mergedPtyIds + ptyIdsByLeafId }) const titlesByLeafId: Record = {} const removedTitleLeafIds = removedTitleLeafIdsRef.current @@ -123,12 +120,26 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup if (Object.keys(titlesByLeafId).length > 0) { layout.titlesByLeafId = titlesByLeafId } + const geometryEdit = + intent === 'gesture' && !terminalLayoutNodeEqual(existing?.root, layout.root) setTabLayout(tabId, layout) - const hasRemotePane = Object.values(mergedPtyIds).some( - (ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId) - ) + const hasRemotePane = [ + ...Object.values(ptyIdsByLeafId), + ...currentPanes.map((pane) => paneTransportsRef.current.get(pane.id)?.getPtyId()) + ].some((ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId)) if (hasRemotePane) { remotePaneLayoutPusherRef.current?.push({ worktreeId, tabId, layout, intent }) + } else if (geometryEdit && layout.root) { + const root = layout.root + commitPendingTerminalChange( + useAppStore.getState(), + { worktreeId, tabId, change: 'layout', root }, + () => + terminalTabPanesNamed(useAppStore.subscribe, useAppStore.getState, tabId).then(() => + // Why optional: an older preload can linger through an in-place renderer reload. + globalThis.window?.api?.session?.setTerminalLayout?.({ worktreeId, tabId, root }) + ) + ) } for (const leafId of currentLeafIds) { clearedScrollbackLeafIds.delete(leafId) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts index 879b9509e3f3..41d73b11508e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef } from 'react' +import { useEffect, useLayoutEffect } from 'react' import { applyExpandedLayoutTo, cancelPendingPaneSizeRefreshFrames, @@ -6,14 +6,9 @@ import { } from './expand-collapse' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' -import { - isHostAuthoritativeLayout, - planTerminalLiveLayoutInsertions, - planTerminalLiveLayoutRemovals, - selectRetiredPaneIds, - trackRetiredLeafIds -} from './terminal-live-layout-reconciliation' -import { collectLeafIds } from './terminal-pane-layout-tree' +import { useAppStore } from '@/store' +import { pendingTerminalLeafIds } from '@/store/terminals/terminal-pending-panes' +import { reconcileMountedTerminalLayout } from './terminal-live-layout-reconciliation' import { useTerminalPaneProcessExitActions } from './use-terminal-pane-process-exit-actions' import type { TerminalPaneCloseController } from './use-terminal-pane-close-actions' @@ -22,108 +17,38 @@ export function useTerminalPaneReconciliation(controller: TerminalPaneCloseContr activityIsolationSnapshotRef, closeTerminalLinkActions, containerRef, - executeClosePane, isActive, isRendererVisible, isolatedPaneKey, managerRef, paneCount, paneLayoutRevision, - paneTransportsRef, pendingPaneSizeRefreshFrameIdsRef, - persistLayoutSnapshot, - ptyRecoveryStatesByPaneId, restoredLayout, tabId } = controller - // Leaves the last host-authoritative layout named, and the ones it has since - // dropped whose panes are still mounted; a removal needs the host to have - // named the leaf before it dropped it, and may have to wait for the PTY exit. - const hostLayoutLeafIdsRef = useRef>(new Set()) - const retiredLeafIdsRef = useRef>(new Set()) - useEffect(() => { closeTerminalLinkActions() }, [closeTerminalLinkActions, isActive, isRendererVisible, paneLayoutRevision]) useEffect(() => { const manager = managerRef.current - if (!manager || !restoredLayout.root) { + const root = restoredLayout.root + if (!manager || !root) { return } - if ( - !isHostAuthoritativeLayout({ - isWebClient: !!(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__, - ptyIdsByLeafId: restoredLayout.ptyIdsByLeafId - }) - ) { - return - } - const layoutLeafIds = new Set(collectLeafIds(restoredLayout.root)) - const mountedLeafIds = manager.getPanes().map((pane) => pane.leafId) - const retiredLeafIds = trackRetiredLeafIds({ - retiredLeafIds: retiredLeafIdsRef.current, - previousLayoutLeafIds: hostLayoutLeafIdsRef.current, - layoutLeafIds, - mountedLeafIds - }) - hostLayoutLeafIdsRef.current = layoutLeafIds - retiredLeafIdsRef.current = retiredLeafIds - const insertions = planTerminalLiveLayoutInsertions(restoredLayout.root, mountedLeafIds) - const removals = planTerminalLiveLayoutRemovals( - restoredLayout.root, - mountedLeafIds, - retiredLeafIds + const { pendingTerminalPanes } = useAppStore.getState() + const panesChanged = reconcileMountedTerminalLayout( + manager, + { root, ptyIdsByLeafId: restoredLayout.ptyIdsByLeafId }, + { + added: pendingTerminalLeafIds(pendingTerminalPanes, tabId, 'add'), + removed: pendingTerminalLeafIds(pendingTerminalPanes, tabId, 'remove') + } ) - if (insertions.length === 0 && removals.length === 0) { + if (!panesChanged) { return } - let appliedInsertion = false - for (const insertion of insertions) { - const ptyId = restoredLayout.ptyIdsByLeafId?.[insertion.newLeafId] - const sourcePaneId = manager.getNumericIdForLeaf(insertion.sourceLeafId) - if (!ptyId || sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId)) { - continue - } - const splitRatio = - insertion.ratio === undefined - ? undefined - : insertion.placement === 'before' - ? 1 - insertion.ratio - : insertion.ratio - const createdPane = manager.splitPaneAroundLeafIds( - insertion.sourceLeafIds, - sourcePaneId, - insertion.direction, - { - ...(splitRatio !== undefined && { ratio: splitRatio }), - leafId: insertion.newLeafId, - ptyId, - placement: insertion.placement - } - ) - if (createdPane) { - appliedInsertion = true - } - } - // Why: the host retired these leaves (its PTY for them ended), so their panes - // would otherwise outlive the layout as blank ghosts and take the tab's next - // close for themselves. selectRetiredPaneIds closes only a pane whose PTY has - // already cleared, so this never kills a still-live remote terminal; a leaf - // whose PTY is still ending is kept retired and removed on the re-run the - // transport's recovery-state change (ptyRecoveryStatesByPaneId) triggers. - // executeClosePane runs the same cleanup a user close does. - const retiredPaneIds = selectRetiredPaneIds(removals, { - paneCount: manager.getPanes().length, - paneIdForLeaf: (leafId) => manager.getNumericIdForLeaf(leafId), - ptyIdForPane: (paneId) => paneTransportsRef.current.get(paneId)?.getPtyId() - }) - for (const paneId of retiredPaneIds) { - executeClosePane(paneId) - } - if (appliedInsertion) { - persistLayoutSnapshot() - } const activePaneId = restoredLayout.activeLeafId ? manager.getNumericIdForLeaf(restoredLayout.activeLeafId) : null @@ -132,18 +57,8 @@ export function useTerminalPaneReconciliation(controller: TerminalPaneCloseContr if (nextActivePaneId !== null) { manager.setActivePane(nextActivePaneId, { focus: isActive }) } - // Why ptyRecoveryStatesByPaneId: a host-retired pane whose PTY has not yet - // finished ending is kept until this re-run, when its transport reports a new - // recovery state and its PTY has cleared. // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - }, [ - executeClosePane, - isActive, - paneCount, - persistLayoutSnapshot, - ptyRecoveryStatesByPaneId, - restoredLayout - ]) + }, [isActive, paneCount, restoredLayout]) useLayoutEffect(() => { const snapshots = activityIsolationSnapshotRef.current diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts index ad6f7de8714b..71547fe2dd29 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts @@ -110,7 +110,18 @@ describe('createTerminalProviderSnapshotBoundPtyIdsSelector', () => { } expectRecollect('pty bound to a leaf', () => { - useAppStore.getState().replaceTerminalLayoutPanePtyId(tabId(0), 'leaf-b-0', 'pty-0-b-next') + useAppStore.setState((state) => { + const layout = state.terminalLayoutsByTabId[tabId(0)] + return { + terminalLayoutsByTabId: { + ...state.terminalLayoutsByTabId, + [tabId(0)]: { + ...layout, + ptyIdsByLeafId: { ...layout?.ptyIdsByLeafId, 'leaf-b-0': 'pty-0-b-next' } + } + } + } + }) }) expect(boundPtyIds).toContain('pty-0-b-next') diff --git a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts b/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts index 3884704d2a1e..ba6576448fc5 100644 --- a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts +++ b/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest' import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport } from '../../../../shared/remote-workspace-types' import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' import type { AppState } from '../../store/types' @@ -135,15 +135,9 @@ export function appState(overrides: Record = {}): AppState { export function createHarness( state: AppState, - get: (args: { targetId: string }) => Promise, - patchResult: RemoteWorkspaceObservedPatchResult = { ok: true, snapshot: snapshot(1) } + get: (args: { targetId: string }) => Promise ) { - const setForConnectedTargets = vi.fn(async () => [ - { - targetId: owner.targetId, - result: patchResult - } - ]) + const importPeerTopology = vi.fn(async (_pull: RemoteWorkspacePeerImport) => {}) let current = true let catalogRevision = 1 const stateListeners = new Set<(current: AppState, previous: AppState) => void>() @@ -191,7 +185,7 @@ export function createHarness( return () => stateListeners.delete(listener) } }, - remoteWorkspace: { get, setForConnectedTargets }, + remoteWorkspace: { get, importPeerTopology }, getCurrentAuthority: () => (current ? owner : null), isPreparationTokenCurrent: (candidate) => current && candidate.catalogRevision === catalogRevision, @@ -201,7 +195,7 @@ export function createHarness( }) return { sync, - setForConnectedTargets, + importPeerTopology, publishState, capturePreparationInput, prepareOnly, diff --git a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts index 161d6eaf576a..5a05d75e183e 100644 --- a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts @@ -1,4 +1,5 @@ import { useAppStore } from '../../store' +import { applyRemoteWorkspacePushStatusEvent } from '../remote-workspace-push-status' import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' export function registerRemoteWorkspaceIpcBridge( @@ -45,6 +46,9 @@ export function registerRemoteWorkspaceIpcBridge( }) }) })() - }) + }), + window.api.remoteWorkspace.onPushStatus((event) => + applyRemoteWorkspacePushStatusEvent(useAppStore.getState(), event) + ) ) } diff --git a/src/renderer/src/hooks/remote-workspace-push-status-event.test.ts b/src/renderer/src/hooks/remote-workspace-push-status-event.test.ts new file mode 100644 index 000000000000..093090e570f4 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-push-status-event.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RemoteWorkspacePushStatusEvent } from '../../../shared/remote-workspace-types' +import { i18n } from '@/i18n/i18n' +import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization' +import { appState, snapshot } from './__tests__/remote-workspace-target-sync-test-harness' +import { applyRemoteWorkspacePushStatusEvent } from './remote-workspace-push-status' + +const TOKEN = snapshot(4).hostObservationToken + +function hydratedState(overrides: Parameters[0] = {}) { + return appState({ + remoteWorkspaceHydratedTargetIds: new Set(['target-a']), + remoteWorkspaceSyncStatusByTargetId: { + 'target-a': { phase: 'synced', revision: 4, hostObservationToken: TOKEN } + }, + ...overrides + }) +} + +function pushed(result: RemoteWorkspacePushStatusEvent['result']): RemoteWorkspacePushStatusEvent { + return { + targetId: 'target-a', + authority: { revision: 4, hostObservationToken: TOKEN }, + result + } +} + +describe('applyRemoteWorkspacePushStatusEvent', () => { + it('acknowledges a pending layout edit the host now holds, and only that one', () => { + const held = { targetId: 'target-a', root: null } + const otherRoot = { + targetId: 'target-a', + root: { type: 'leaf' as const, leafId: '11111111-1111-4111-8111-111111111111' } + } + const state = hydratedState({ + pendingDirectSshLayoutEditsByTabId: { 'tab-a': held, 'tab-b': otherRoot } + }) + const uploaded = snapshot(5) + uploaded.session.terminalLayoutsByTabId = { + 'tab-a': { root: null, activeLeafId: null, expandedLeafId: null }, + 'tab-b': { root: null, activeLeafId: null, expandedLeafId: null } + } + + applyRemoteWorkspacePushStatusEvent(state, pushed({ ok: true, snapshot: uploaded })) + + expect(state.acknowledgeDirectSshLayoutEdits).toHaveBeenCalledWith({ 'tab-a': held }) + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledWith( + 'target-a', + expect.objectContaining({ phase: 'synced', direction: 'push', revision: 5 }) + ) + }) + + it.each([ + ['stale-revision', 'Workspace changed on another device'], + ['unavailable', 'Remote workspace sync unavailable'] + ] as const)('localizes the %s export fallback', async (reason, message) => { + const previousLanguage = i18n.language + await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE) + try { + const state = hydratedState() + + applyRemoteWorkspacePushStatusEvent(state, pushed({ ok: false, reason })) + + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith( + 'target-a', + expect.objectContaining({ message: `[${message}]` }) + ) + } finally { + await i18n.changeLanguage(previousLanguage) + } + }) + + it('reports a thrown export as an error', () => { + const state = hydratedState() + + applyRemoteWorkspacePushStatusEvent(state, { ...pushed(null), error: 'socket closed' }) + + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledWith( + 'target-a', + expect.objectContaining({ phase: 'error', direction: 'push', message: 'socket closed' }) + ) + }) + + it.each([ + ['mid-pull', { remoteWorkspaceHydratedTargetIds: new Set() }], + [ + 'conflicted', + { + remoteWorkspaceSyncStatusByTargetId: { + 'target-a': { phase: 'conflict' as const, revision: 4, hostObservationToken: TOKEN } + } + } + ], + [ + 'observing another host', + { + remoteWorkspaceSyncStatusByTargetId: { + 'target-a': { phase: 'synced' as const, revision: 4, hostObservationToken: 'other' } + } + } + ] + ])('ignores a report while the window is %s', (_name, overrides) => { + const acknowledgeDirectSshLayoutEdits = vi.fn() + const state = hydratedState({ ...overrides, acknowledgeDirectSshLayoutEdits }) + + applyRemoteWorkspacePushStatusEvent(state, pushed({ ok: true, snapshot: snapshot(5) })) + + expect(state.setRemoteWorkspaceSyncStatus).not.toHaveBeenCalled() + expect(acknowledgeDirectSshLayoutEdits).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-push-status.ts b/src/renderer/src/hooks/remote-workspace-push-status.ts index 4a3792114817..0008286da87a 100644 --- a/src/renderer/src/hooks/remote-workspace-push-status.ts +++ b/src/renderer/src/hooks/remote-workspace-push-status.ts @@ -1,5 +1,9 @@ -import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' +import type { + RemoteWorkspaceObservedPatchResult, + RemoteWorkspacePushStatusEvent +} from '../../../shared/remote-workspace-types' import { translate } from '@/i18n/i18n' +import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import type { AppState } from '../store/types' export type RemoteWorkspacePushAuthority = { @@ -73,3 +77,48 @@ export function applyRemoteWorkspacePushStatus( }) } } + +/** + * Main's report of an export it ran. Ignored once the window no longer holds that host's agreement: + * mid-pull, after a conflict, or against another host observation. + */ +export function applyRemoteWorkspacePushStatusEvent( + store: AppState, + { targetId, authority, result, error }: RemoteWorkspacePushStatusEvent +): void { + const status = store.remoteWorkspaceSyncStatusByTargetId[targetId] + if ( + !store.remoteWorkspaceHydratedTargetIds.has(targetId) || + status?.phase === 'conflict' || + status?.hostObservationToken !== authority.hostObservationToken + ) { + return + } + if (error !== undefined) { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: 'error', + direction: 'push', + revision: status.revision ?? authority.revision, + updatedAt: status.updatedAt, + hostObservationToken: authority.hostObservationToken, + message: error + }) + return + } + if (result?.ok) { + // An edit is acknowledged once the host holds its exact layout. + store.acknowledgeDirectSshLayoutEdits( + Object.fromEntries( + Object.entries(store.pendingDirectSshLayoutEditsByTabId).filter(([tabId, edit]) => { + const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] + return ( + edit.targetId === targetId && + uploaded && + terminalLayoutNodeEqual(edit.root, uploaded.root) + ) + }) + ) + ) + } + applyRemoteWorkspacePushStatus(store, targetId, result ?? undefined, authority) +} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts index 020d7d43c923..4e485b172dee 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts @@ -158,7 +158,8 @@ describe('session writes deferred by a direct-SSH apply', () => { finalizeHydratedTerminals: () => { onApplied() return 0 - } + }, + importPeerTopology: async () => {} }) expect(result).toBe('applied') } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 9dbe57cf0bc8..03198abeb1e2 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -1,9 +1,18 @@ -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' +import type { + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport +} from '../../../shared/remote-workspace-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' import type { DirectSshAuthority } from '../../../shared/ssh-types' import { toSshExecutionHostId } from '../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { buildWorkspaceSessionPayload } from '../lib/workspace-session' +import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' +import { buildWorkspaceSessionHostPatches } from '../lib/workspace-session-host-persistence' import type { AppState } from '../store/types' import { admitDirectSshSnapshotApplyToken, @@ -79,6 +88,8 @@ type RemoteWorkspaceSnapshotApplyInput = { isPreparationTokenCurrent: (token: DirectSshPreparationToken) => boolean waitForWorkspaceSessionReady: (signal?: AbortSignal) => Promise finalizeHydratedTerminals: (authority: DirectSshAuthority) => number + /** Main commits the import and records it as agreed with the host, so it never exports back. */ + importPeerTopology: (pull: RemoteWorkspacePeerImport) => Promise /** * Host paths still carrying terminal tabs when this apply gave up placing them. `unverifiable`, * never proof the rows are not ours, so the caller owns getting back to a placed picture — this @@ -89,6 +100,41 @@ type RemoteWorkspaceSnapshotApplyInput = { export type RemoteWorkspaceSnapshotApplyResult = 'applied' | 'stale' | 'failed' +/** The fields the merge rewrote; the rest of its result is the window's own session. */ +function mergedFields( + merged: WorkspaceSessionState, + current: WorkspaceSessionState +): WorkspaceSessionPatch { + const before = new Map(Object.entries(current)) + return Object.fromEntries( + Object.entries(merged).filter(([field, value]) => value !== before.get(field)) + ) +} + +/** Whether the merge kept, on the target's worktrees, tabs or layout edits the host does not hold. */ +function mergeKeptLocalState( + merged: WorkspaceSessionState, + remote: WorkspaceSessionState, + worktreeIds: ReadonlySet, + localLayoutTabIds: ReadonlySet +): boolean { + const hostTabIds = new Set( + Object.values(remote.tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) + return ( + [...worktreeIds].some((worktreeId) => + (merged.tabsByWorktree[worktreeId] ?? []).some((tab) => !hostTabIds.has(tab.id)) + ) || + [...localLayoutTabIds].some( + (tabId) => + !terminalLayoutNodeEqual( + merged.terminalLayoutsByTabId[tabId]?.root, + remote.terminalLayoutsByTabId[tabId]?.root + ) + ) + ) +} + function currentRecoveryTabIds( state: AppState, authority: DirectSshAuthority, @@ -122,6 +168,7 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ isPreparationTokenCurrent, waitForWorkspaceSessionReady, finalizeHydratedTerminals, + importPeerTopology, onUnplacedTabWorktreePaths }: RemoteWorkspaceSnapshotApplyInput): Promise { const { authority } = token @@ -175,24 +222,25 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ onUnplacedTerminalTabs: (worktreePath) => unplacedTabWorktreePaths.push(worktreePath) }) } + const current = buildWorkspaceSessionPayload(state) + const localLayoutTabIds = new Set( + [...worktreeIds].flatMap((id) => + (state.tabsByWorktree[id] ?? []) + .filter( + (tab) => state.pendingDirectSshLayoutEditsByTabId[tab.id]?.targetId === authority.targetId + ) + .map((tab) => tab.id) + ) + ) const merged = mergeDirectSshRemoteWorkspaceSession( - buildWorkspaceSessionPayload(state), + current, remoteSession, worktreeIds, state.tabsByWorktree, currentRecoveryTabIds(state, authority, worktreeIds), toSshExecutionHostId(authority.targetId), state.closedTerminalTabTombstonesByTabId, - new Set( - [...worktreeIds].flatMap((id) => - (state.tabsByWorktree[id] ?? []) - .filter( - (tab) => - state.pendingDirectSshLayoutEditsByTabId[tab.id]?.targetId === authority.targetId - ) - .map((tab) => tab.id) - ) - ) + localLayoutTabIds ) if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { return 'stale' @@ -203,6 +251,32 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ try { const currentStore = store.getState() const replaceWorkspaceKeys = [...worktreeIds] + // Sent before the window's own apply; IPC order puts it ahead of any save that apply causes. + importPeerTopology({ + targetId: authority.targetId, + revision: snapshot.revision, + hostObservationToken: snapshot.hostObservationToken, + outcome: hasUnplacedTerminalTabs + ? 'conflict' + : mergeKeptLocalState(merged, remoteSession, worktreeIds, localLayoutTabIds) + ? 'kept-local' + : 'synced', + mirroredTopologySeqByWorktree: state.terminalTopologySeqByWorktree, + // Only the target's partition: the merge carries the window's copy of every other host's rows. + session: + buildWorkspaceSessionHostPatches(mergedFields(merged, current), currentStore).find( + ({ hostId }) => hostId === toSshExecutionHostId(authority.targetId) + )?.patch ?? {} + }).catch((error: unknown) => { + // Main holds none of the import, so the target is not synced, whatever this apply showed. + if (isArrivalCurrent(authority.targetId, arrival) && isPreparationTokenCurrent(token)) { + store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + direction: 'pull', + message: error instanceof Error ? error.message : 'Workspace sync failed' + }) + } + }) currentStore.hydrateWorkspaceSession(merged, { directSshAuthority: authority, replaceWorkspaceKeys @@ -224,15 +298,16 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ // The host listed tabs on paths this client cannot place, so adopting zero of them is not // the host's picture. `conflict` is the phase that says exactly that, and it is load-bearing // twice over: - // - use-app-session-persistence.ts filters conflicted targets out of uploads, and an - // upload is a `replace-session` patch (remote-workspace-relay-sync.ts) that wholesale + // - main keeps conflicted targets out of exports (remote-workspace-export.ts), and an + // export is a `replace-session` patch (remote-workspace-relay-sync.ts) that wholesale // replaces the host snapshot - it would delete the very tabs we failed to adopt; // - workspace-terminal-host-authority.ts treats `offline`/`error` on an un-hydrated target // as `none`, its bounded floor, which authorises seeding AND sleeping-agent resume. // `conflict` is deliberately not in that set: the unplaced paths stay `unverifiable`, // and only the worktrees this apply did place read `none`. // Hydration is cleared, not merely withheld: the set is add-only, so a target that synced - // cleanly before would otherwise keep uploading from this incomplete picture (STA-3593). + // cleanly before would otherwise keep reading as hydrated from this incomplete picture + // (STA-3593). currentStore.clearRemoteWorkspaceHydrated(authority.targetId) currentStore.setRemoteWorkspaceSyncStatus(authority.targetId, { phase: 'conflict', diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts index b9e70bf86c2d..32331c619f5b 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts @@ -95,7 +95,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts index 58a5a02a0bfa..e46f37155f8c 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts @@ -94,7 +94,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts new file mode 100644 index 000000000000..a088623afc1d --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts @@ -0,0 +1,257 @@ +/** + * The window commits a host snapshot's import through main in one call, before applying it, so + * main records the agreement that keeps the import from exporting back. + */ +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport +} from '../../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' +import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' +import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const TARGET_ID = 'ssh-target-1' +const PATH = '/srv/proj/bug-cats' +const WORKTREE_ID = `repoA::${PATH}` + +const authority: DirectSshAuthority = { + targetId: TARGET_ID, + providerEpoch: 'provider-epoch-1' as SshProviderEpoch, + connectionGeneration: 1 +} + +function token(snapshotRevision: number): DirectSshSnapshotApplyToken { + return { + authority, + catalogRevision: 0, + repoFingerprint: 'fp', + authorityRequirement: 'required', + snapshotRevision, + outcome: 'complete' + } +} + +function snapshot(revision: number, tabIds: readonly string[]): RemoteWorkspaceObservedSnapshot { + return { + namespace: 'workspace', + revision, + updatedAt: revision, + schemaVersion: 1, + hostObservationToken: `observation-${revision}`, + session: { + activeWorktreePath: PATH, + activeTabId: tabIds[0] ?? null, + tabsByWorktreePath: { + [PATH]: tabIds.map((tabId, index) => ({ + id: tabId, + worktreePath: PATH, + ptyId: `pty-${tabId}`, + title: `Terminal ${index + 1}`, + customTitle: null, + color: null, + sortOrder: index, + createdAt: index + 1 + })) + }, + terminalLayoutsByTabId: {}, + activeWorktreePathsOnShutdown: [], + activeTabIdByWorktreePath: { [PATH]: tabIds[0] ?? null }, + remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])), + lastVisitedAtByWorktreePath: { [PATH]: revision }, + defaultTerminalTabsAppliedByWorktreePath: { [PATH]: true } + } + } satisfies RemoteWorkspaceObservedSnapshot +} + +type TestStore = ReturnType + +function seedCatalog(store: TestStore): void { + store.setState({ + worktreesByRepo: { + repoA: [ + makeWorktree({ + id: WORKTREE_ID, + repoId: 'repoA', + path: PATH, + hostId: `ssh:${TARGET_ID}` + } as never) + ] + }, + repos: [ + { + id: 'repoA', + path: '/srv/proj', + displayName: 'Proj', + badgeColor: '#000', + addedAt: 0, + connectionId: TARGET_ID + } as never + ], + // Why: only the IPC-backed reconnect is stubbed; hydration bookkeeping stays real because it is + // the very signal under test. + reconnectPersistedTerminals: (async () => {}) as never, + setRemoteWorkspaceSyncStatus: (() => {}) as never + }) +} + +async function importOf( + store: TestStore, + snap: RemoteWorkspaceObservedSnapshot +): Promise { + const importPeerTopology = vi.fn(async (_pull: RemoteWorkspacePeerImport) => {}) + await applyDirectSshRemoteWorkspaceSnapshot({ + store, + snapshot: snap, + token: token(snap.revision), + arrival: 1, + isArrivalCurrent: () => true, + isPreparationTokenCurrent: () => true, + waitForWorkspaceSessionReady: async () => true, + finalizeHydratedTerminals: () => 0, + importPeerTopology + }) + expect(importPeerTopology).toHaveBeenCalledOnce() + return importPeerTopology.mock.calls[0][0] +} + +describe('direct SSH snapshot import commit', () => { + it('sends the merge to the worktree partition with the pull it came from', async () => { + const store = createTestStore() + seedCatalog(store) + + const pull = await importOf(store, snapshot(6, ['host-tab'])) + + expect(pull).toMatchObject({ + targetId: TARGET_ID, + revision: 6, + hostObservationToken: 'observation-6', + outcome: 'synced' + }) + expect(pull.session.tabsByWorktree?.[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['host-tab']) + // Only what the merge rewrote; editor and browser state stay the window's own writes. + const mergedFields = new Set([ + 'activeRepoId', + 'activeWorktreeId', + 'activeWorkspaceKey', + 'activeTabId', + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'activeWorktreeIdsOnShutdown', + 'activeTabIdByWorktree', + 'remoteSessionIdsByTabId', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' + ]) + expect(Object.keys(pull.session).filter((field) => !mergedFields.has(field))).toEqual([]) + }) + + it("sends none of the window's rows for other partitions", async () => { + const store = createTestStore() + seedCatalog(store) + // The window's copy of a local worktree; main may already hold a newer one. + const localWorktreeId = 'repoL::/home/me/proj' + const localTab = { + id: 'local-tab', + ptyId: 'pty-local', + worktreeId: localWorktreeId, + title: 'local', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + store.setState({ + tabsByWorktree: { [localWorktreeId]: [localTab] }, + terminalLayoutsByTabId: { + [localTab.id]: { root: null, activeLeafId: null, expandedLeafId: null } + } + }) + + const pull = await importOf(store, snapshot(6, ['host-tab'])) + + expect(Object.keys(pull.session.tabsByWorktree ?? {})).toEqual([WORKTREE_ID]) + expect(pull.session.terminalLayoutsByTabId ?? {}).not.toHaveProperty(localTab.id) + }) + + it('reports kept-local when the merge keeps a tab the host has not seen', async () => { + const store = createTestStore() + seedCatalog(store) + await importOf(store, snapshot(1, ['agent'])) + // Created during the download, so the host's next snapshot still lists only `agent`. + store.setState({ + tabsByWorktree: { + [WORKTREE_ID]: [ + ...(store.getState().tabsByWorktree[WORKTREE_ID] ?? []), + { + id: 'setup', + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'setup', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + } + }) + + await expect(importOf(store, snapshot(2, ['agent']))).resolves.toMatchObject({ + outcome: 'kept-local' + }) + await expect(importOf(store, snapshot(3, ['agent', 'setup']))).resolves.toMatchObject({ + outcome: 'synced' + }) + }) + + it('reports a conflict when the window cannot place every host tab', async () => { + vi.useFakeTimers() + try { + const store = createTestStore() + seedCatalog(store) + store.setState({ worktreesByRepo: {} }) + const pending = importOf(store, snapshot(7, ['host-tab'])) + await vi.runAllTimersAsync() + + await expect(pending).resolves.toMatchObject({ revision: 7, outcome: 'conflict' }) + } finally { + vi.useRealTimers() + } + }) + + it('reports an error, not synced, when main fails to commit the import', async () => { + const store = createTestStore() + seedCatalog(store) + const setRemoteWorkspaceSyncStatus = vi.fn() + store.setState({ setRemoteWorkspaceSyncStatus }) + + await applyDirectSshRemoteWorkspaceSnapshot({ + store, + snapshot: snapshot(6, ['host-tab']), + token: token(6), + arrival: 1, + isArrivalCurrent: () => true, + isPreparationTokenCurrent: () => true, + waitForWorkspaceSessionReady: async () => true, + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => { + throw new Error('import failed') + } + }) + + expect(setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith(TARGET_ID, { + phase: 'error', + direction: 'pull', + message: 'import failed' + }) + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts index 69660e1e6ea4..fcda90721f0c 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts @@ -98,7 +98,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts index 7d8fc937cc91..429b4b8409af 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts @@ -209,7 +209,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) // Exercise the real placement deadline without spending ten wall-clock seconds per snapshot. await vi.advanceTimersByTimeAsync(10_000) @@ -251,7 +252,8 @@ describe('a host snapshot whose terminal tabs cannot be placed locally', () => { isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) await Promise.resolve() await Promise.resolve() diff --git a/src/renderer/src/hooks/remote-workspace-target-sync-types.ts b/src/renderer/src/hooks/remote-workspace-target-sync-types.ts index 40d5b488c415..04f8247818a7 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync-types.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync-types.ts @@ -1,8 +1,7 @@ import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport } from '../../../shared/remote-workspace-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { DirectSshAuthority } from '../../../shared/ssh-types' import type { DirectSshPreparationInput, @@ -13,12 +12,7 @@ import type { RemoteWorkspaceSnapshotPlacementStore } from './remote-workspace-s export type RemoteWorkspaceApi = { get: (args: { targetId: string }) => Promise - setForConnectedTargets: (args: { - session?: WorkspaceSessionState - hydratedTargetIds?: string[] - expectedRevisionsByTargetId: Record - expectedHostObservationTokensByTargetId: Record - }) => Promise<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]> + importPeerTopology: (pull: RemoteWorkspacePeerImport) => Promise } export type RemoteWorkspaceTargetSyncDeps = { diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 65f7d2c02041..b89f086a3768 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -1,13 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { - RemoteWorkspaceObservedPatchResult, RemoteWorkspaceObservedSnapshot, RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types' import type { SshProviderEpoch } from '../../../shared/ssh-types' -import { i18n } from '@/i18n/i18n' -import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization' -import type { DirectSshPreparationInput } from './direct-ssh-reconnect-coordinator' import { appState, createHarness, @@ -21,84 +17,25 @@ import { } from './__tests__/remote-workspace-target-sync-test-harness' describe('createRemoteWorkspaceTargetSync', () => { - it('captures local tabs before get when deciding a revision-zero upload', async () => { + it('records a revision-zero pull with main, which seeds the empty host', async () => { const state = appState({ tabsByWorktree: { 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] } }) - const pendingGet = deferred() - const harness = createHarness(state, () => pendingGet.promise) - - const pending = harness.sync.syncAfterConnect(token()) - await flush() - state.tabsByWorktree = {} - pendingGet.resolve(snapshot(0)) - await pending - - expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() - expect(harness.setForConnectedTargets).toHaveBeenCalledWith( - expect.objectContaining({ - hydratedTargetIds: ['target-a'], - expectedRevisionsByTargetId: { 'target-a': 0 } - }) - ) - }) - - it('acknowledges the captured blank-host edit when a newer null-root edit arrives', async () => { - const firstEdit = { targetId: 'target-a', root: null } - const newerEdit = { targetId: 'target-a', root: null } - const acknowledgeDirectSshLayoutEdits = vi.fn() - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - }, - pendingDirectSshLayoutEditsByTabId: { 'tab-a': firstEdit }, - acknowledgeDirectSshLayoutEdits - }) const harness = createHarness(state, async () => snapshot(0)) - const uploaded = snapshot(1) - uploaded.session.terminalLayoutsByTabId = { - 'tab-a': { root: null, activeLeafId: null, expandedLeafId: null } - } - harness.setForConnectedTargets.mockImplementationOnce(async () => { - state.pendingDirectSshLayoutEditsByTabId = { 'tab-a': newerEdit } - return [{ targetId: owner.targetId, result: { ok: true, snapshot: uploaded } }] - }) await harness.sync.syncAfterConnect(token()) - const acknowledged = acknowledgeDirectSshLayoutEdits.mock.calls[0]?.[0]?.['tab-a'] - expect(acknowledged).toBe(firstEdit) - expect(state.pendingDirectSshLayoutEditsByTabId['tab-a']).toBe(newerEdit) - }) - - it.each([ - ['stale-revision', 'Workspace changed on another device'], - ['unavailable', 'Remote workspace sync unavailable'] - ] as const)('localizes the %s upload fallback', async (reason, message) => { - const previousLanguage = i18n.language - await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE) - try { - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - } - }) - const harness = createHarness(state, async () => snapshot(0), { - ok: false, - reason - }) - - await harness.sync.syncAfterConnect(token()) - - expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith( - 'target-a', - expect.objectContaining({ message: `[${message}]` }) - ) - } finally { - await i18n.changeLanguage(previousLanguage) - } + expect(state.markRemoteWorkspaceHydrated).toHaveBeenCalledWith('target-a') + expect(harness.importPeerTopology).toHaveBeenCalledOnce() + expect(harness.importPeerTopology).toHaveBeenCalledWith({ + targetId: 'target-a', + revision: 0, + hostObservationToken: snapshot(0).hostObservationToken, + outcome: 'kept-local', + session: {} + }) }) it('publishes nothing from a snapshot response after its authority turns stale', async () => { @@ -612,45 +549,6 @@ describe('createRemoteWorkspaceTargetSync', () => { } }) - it('does not publish a revision-zero push after a newer snapshot arrives', async () => { - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - } - }) - const pendingPush = - deferred<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]>() - const pendingCapture = deferred() - const harness = createHarness(state, async () => snapshot(0)) - harness.setForConnectedTargets.mockImplementationOnce(() => pendingPush.promise) - - const first = harness.sync.syncAfterConnect(token()) - await flush() - expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() - - harness.capturePreparationInput.mockImplementationOnce(() => pendingCapture.promise) - const second = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(85)) - await flush() - pendingPush.resolve([{ targetId: 'target-a', result: { ok: true, snapshot: snapshot(1) } }]) - await first - - expect(state.setRemoteWorkspaceSyncStatus).not.toHaveBeenCalledWith( - 'target-a', - expect.objectContaining({ direction: 'push' }) - ) - - harness.sync.stop() - pendingCapture.resolve({ - ...owner, - catalogRevision: 1, - repoRefs: [{ repoId: 'repo-a', executionHostId: 'ssh:target-a' }], - authorityRequirement: 'required', - reason: 'workspace-snapshot', - snapshotRevision: 85 - }) - await second - }) - it('stopping snapshot sync cancels the active placement waiter immediately', async () => { vi.useFakeTimers() const state = appState({ worktreesByRepo: {} }) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 05d027ece2e7..051e9041f684 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -1,7 +1,6 @@ import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' import type { DirectSshAuthority } from '../../../shared/ssh-types' import { translate } from '@/i18n/i18n' -import { buildWorkspaceSessionPayload } from '../lib/workspace-session' import type { DirectSshPreparationToken, DirectSshSnapshotApplyToken @@ -11,8 +10,6 @@ import { resolveExactDirectSshTargetWorktreeIds } from './remote-workspace-snaps import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' import { createRemoteWorkspaceSnapshotArrivalCoordinator } from './remote-workspace-snapshot-arrival-coordinator' import { createDeferredSnapshotPlacementRetries } from './remote-workspace-deferred-placement-retry' -import { applyRemoteWorkspacePushStatus } from './remote-workspace-push-status' -import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { waitForRemoteWorkspaceSessionReady } from './remote-workspace-session-readiness' import type { RemoteWorkspaceTargetSync, @@ -79,6 +76,7 @@ export function createRemoteWorkspaceTargetSync( waitForWorkspaceSessionReady: (signal) => waitForRemoteWorkspaceSessionReady(deps.store, signal), finalizeHydratedTerminals: deps.finalizeHydratedTerminals, + importPeerTopology: deps.remoteWorkspace.importPeerTopology, onUnplacedTabWorktreePaths: (worktreePaths) => { unplacedTabWorktreePaths = worktreePaths } @@ -178,49 +176,30 @@ export function createRemoteWorkspaceTargetSync( } return } - deps.store.getState().markRemoteWorkspaceHydrated(authority.targetId) - if (!hasLocalTabs) { - deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'idle', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - hostObservationToken: snapshot.hostObservationToken, - message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') - }) - return - } - if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { - return + const agreed = { + revision: snapshot.revision, + updatedAt: snapshot.updatedAt, + hostObservationToken: snapshot.hostObservationToken } - const pendingLayoutEdits = deps.store.getState().pendingDirectSshLayoutEditsByTabId - const results = await deps.remoteWorkspace.setForConnectedTargets({ - session: buildWorkspaceSessionPayload(deps.store.getState()), - hydratedTargetIds: [authority.targetId], - expectedRevisionsByTargetId: { [authority.targetId]: snapshot.revision }, - expectedHostObservationTokensByTargetId: { - [authority.targetId]: snapshot.hostObservationToken - } + deps.store.getState().markRemoteWorkspaceHydrated(authority.targetId) + deps.store.getState().setRemoteWorkspaceSyncStatus( + authority.targetId, + hasLocalTabs + ? { phase: 'pulling', direction: 'pull', ...agreed } + : { + phase: 'idle', + ...agreed, + message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') + } + ) + // An empty host has nothing to import; main seeds it from this desktop and reports the push. + await deps.remoteWorkspace.importPeerTopology({ + targetId: authority.targetId, + revision: snapshot.revision, + hostObservationToken: snapshot.hostObservationToken, + outcome: 'kept-local', + session: {} }) - if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { - return - } - const result = results.find((entry) => entry.targetId === authority.targetId)?.result - if (result?.ok) { - const currentState = deps.store.getState() - currentState.acknowledgeDirectSshLayoutEdits( - Object.fromEntries( - Object.entries(pendingLayoutEdits ?? {}).flatMap(([tabId, entry]) => { - const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] - return entry.targetId === authority.targetId && - uploaded && - terminalLayoutNodeEqual(entry.root, uploaded.root) - ? [[tabId, entry]] - : [] - }) - ) - ) - } - applyRemoteWorkspacePushStatus(deps.store.getState(), authority.targetId, result, snapshot) } const syncAfterConnect = (token: DirectSshPreparationToken): Promise => diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts index d8bfbd54d4c4..ae3057a646b2 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts @@ -113,7 +113,8 @@ describe('useIpcEvents agent status snapshot integration', () => { onChanged: (cb: typeof onChangedListenerRef.current) => { onChangedListenerRef.current = cb return () => {} - } + }, + onPushStatus: () => () => {} } }) ) diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 9c204026b7aa..af6bced327a9 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -24,6 +24,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'pty.onExit', 'rateLimits.onUpdate', 'remoteWorkspace.onChanged', + 'remoteWorkspace.onPushStatus', 'repos.onChanged', 'runtime.onBrowserDriverChanged', 'runtime.onBrowserRemoteViewersChanged', @@ -198,6 +199,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ssh.onStateChanged', 'ui.onSystemResumed', 'remoteWorkspace.onChanged', + 'remoteWorkspace.onPushStatus', 'ui.onTerminalZoom', 'agentStatus.onSet', 'agentStatus.onClear', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 81cf2f502962..4a8516187045 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19387,6 +19387,9 @@ "paneMove": { "failed": "Couldn't move the pane to a new tab." }, + "topologyChange": { + "refused": "Couldn't save that terminal change, so it was undone." + }, "claudeOldTerminalBanner": { "title": "This terminal was opened before Orca's Claude account update, so claude here uses System default's login.", "dialogTitle": "Why claude here doesn't use the selected account", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 76bbfc173fc2..9bbc6f976243 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19390,6 +19390,9 @@ "paneMove": { "failed": "No se pudo mover el panel a una pestaña nueva." }, + "topologyChange": { + "refused": "No se pudo guardar ese cambio en la terminal, así que se deshizo." + }, "paneTopBanner": { "openNewTerminal": "Abrir terminal nuevo", "learnMore": "Más información", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 99821d665151..b6419dd51094 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19422,6 +19422,9 @@ "paneMove": { "failed": "Impossible de déplacer le volet vers un nouvel onglet." }, + "topologyChange": { + "refused": "Impossible d'enregistrer cette modification du terminal ; elle a été annulée." + }, "paneTopBanner": { "openNewTerminal": "Ouvrir un nouveau terminal", "learnMore": "Apprendre encore plus", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 830d40bd3aef..fcd06a105296 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -19422,6 +19422,9 @@ "paneMove": { "failed": "ペインを新規タブに移動できませんでした。" }, + "topologyChange": { + "refused": "このターミナルの変更を保存できなかったため、元に戻しました。" + }, "paneTopBanner": { "openNewTerminal": "新規ターミナルを開く", "learnMore": "詳しく見る", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 355225c9e462..d97ca7fafd71 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -19422,6 +19422,9 @@ "paneMove": { "failed": "분할 창을 새 탭으로 이동할 수 없습니다." }, + "topologyChange": { + "refused": "터미널 변경 사항을 저장하지 못해 되돌렸습니다." + }, "paneTopBanner": { "openNewTerminal": "새 터미널 열기", "learnMore": "자세히 알아보기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ecadc83a4c58..4b6f8cb77041 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -19422,6 +19422,9 @@ "paneMove": { "failed": "无法将窗格移动到新选项卡。" }, + "topologyChange": { + "refused": "无法保存该终端更改,已将其撤销。" + }, "paneTopBanner": { "openNewTerminal": "打开新终端", "learnMore": "了解更多", diff --git a/src/renderer/src/lib/agent-launch-tab-publication.test.ts b/src/renderer/src/lib/agent-launch-tab-publication.test.ts index 8d9ec4c3f0b8..c22b1512b9c0 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.test.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { AgentLaunchTabPublishRequest } from '../../../shared/agent-launch-tab-publication' import { createTabsSliceMockApi } from '../store/slices/tabs-slice-test-harness' @@ -30,7 +30,7 @@ vi.mock('@/hooks/ipc-events/terminal-command-state', async (importOriginal) => ( focusTerminalInitiatedTab })) -createTabsSliceMockApi() +const mockApi = createTabsSliceMockApi() const { publishAgentLaunchTab } = await import('./agent-launch-tab-publication') const { agentLaunchPanePrompt } = await import('./agent-launch-pane-prompt') @@ -88,6 +88,23 @@ describe('publishing a launch tab before its agent exists', () => { expect(launchTab()?.viewMode).toBe('chat') }) + it('records the tab, its launch and its pane in main before the agent exists', () => { + const createTerminalSurface = vi.fn() + vi.stubGlobal('window', { api: { ...mockApi, session: { createTerminalSurface } } }) + onTestFinished(() => { + vi.unstubAllGlobals() + }) + + publishAgentLaunchTab(request()) + + expect(createTerminalSurface).toHaveBeenCalledWith({ + worktreeId: WT, + tabId: TAB_ID, + leafId: LEAF_ID, + placement: { kind: 'new-tab', row: expect.objectContaining({ launchAgent: 'claude' }) } + }) + }) + it('keeps the prompt for the pane to offer if its agent does not start', () => { publishAgentLaunchTab(request({ prompt: 'fix the build' })) expect(agentLaunchPanePrompt(TAB_ID)).toBe('fix the build') diff --git a/src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts b/src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts index f8398e9ca9f6..82d8a5490423 100644 --- a/src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts +++ b/src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts @@ -17,7 +17,7 @@ const DEFAULT_TABS = { tabs: [{ title: 'Claude' }] } -function preserveRuntimeAuthoredWorkspaceSessionFields>( +function unionDefaultTabMarksLikeMainSave>( next: T, prior: T ): T { @@ -66,7 +66,7 @@ describe('defaultTerminalTabsAppliedByWorktreeId persist round-trip', () => { } // Persist snapshots omit this write-once slice (empty payload / renderer never mentioned it). const omittedSnapshot = getDefaultWorkspaceSession() - const preserved = preserveRuntimeAuthoredWorkspaceSessionFields(omittedSnapshot, patched) + const preserved = unionDefaultTabMarksLikeMainSave(omittedSnapshot, patched) const parsed = parseWorkspaceSession(JSON.parse(JSON.stringify(preserved))) expect(parsed.ok).toBe(true) if (!parsed.ok) { diff --git a/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts b/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts index 8fe8e23de6fb..8139cf71e288 100644 --- a/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts +++ b/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts @@ -42,6 +42,10 @@ function planGeometryWrites( if (!first || !second || extra || !divider || extraDivider) { return false } + // Why: a divider mid-drag is the user's; its drag end persists what wins. + if (divider.classList.contains('is-dragging')) { + return false + } // Why: a zoomed pane hides its siblings and owns their flex; collapse restores saved styles over any write. if (first.style.display === 'none' || second.style.display === 'none') { return false diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index 581d9e875c64..f645e489d3c7 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -6,6 +6,12 @@ import { buildWorkspaceSessionPatch } from './workspace-session-patch' import { createWorktreeTabBucketProjection } from './worktree-tab-bucket-projection' type SessionRelevantField = (typeof SESSION_RELEVANT_FIELDS)[number] + +const MAIN_AUTHORED_SESSION_FIELDS = new Set([ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'sleepingAgentSessionsByPaneKey' +]) type TabsByWorktree = AppState['tabsByWorktree'] type TerminalTab = TabsByWorktree[string][number] type UnifiedTabsByWorktree = AppState['unifiedTabsByWorktree'] @@ -135,6 +141,8 @@ export function createSessionWriteSubscriber({ // identity is the only thing the pre-allocation scan below can compare them against. let prevTabsSource: TabsByWorktree | null = null let prevUnifiedTabsSource: UnifiedTabsByWorktree | null = null + // Only a mirror apply moves this map, so a new identity marks the update as main's own state. + let prevTopologySeq: AppState['terminalTopologySeqByWorktree'] | null = null // Why: this set is the only record that a mutation still owes a write — `prev` has already // advanced past it, and change detection is identity-based, so a field dropped from here can // never be re-detected. In-flight fields remain owned by that write until acknowledgment; @@ -243,6 +251,8 @@ export function createSessionWriteSubscriber({ if (!shouldPersistWorkspaceSession(state)) { return } + const mirrored = prev !== null && state.terminalTopologySeqByWorktree !== prevTopologySeq + prevTopologySeq = state.terminalTopologySeqByWorktree // Why: this fires on every store write and almost none of them touch a session field. Scan // identities first so the common case never allocates the 35-field snapshot or the changed // list; only a real identity change pays for them. @@ -281,7 +291,14 @@ export function createSessionWriteSubscriber({ return } prev = next - for (const field of changedFields) { + // Main already holds the topology a mirror apply wrote; the tab-bar placement it made is ours. + const owedFields = mirrored + ? changedFields.filter((field) => !MAIN_AUTHORED_SESSION_FIELDS.has(field)) + : changedFields + if (mirrored && owedFields.length === 0) { + return + } + for (const field of owedFields) { pendingChangedFields.add(field) } if (shouldSchedulePersist && !shouldSchedulePersist()) { diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts index 0ac2a9d5f647..43366dc9b4c7 100644 --- a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -13,8 +13,9 @@ import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation- /** * STA-9417, converted from the natural repro: a host-created worktree whose setup script runs in a * split of the (unmounted) primary tab, then the first activation of that worktree. When main's - * split reveal does not reach the window, the activation sweep mints a second tab for the setup - * PTY, so two saved panes own one terminal and one of them freezes. + * split reveal did not reach the window, the activation sweep minted a second tab for the setup + * PTY, so two saved panes owned one terminal and one of them froze. Main's topology push now + * carries the split whether or not the reveal arrives. */ const initialState = useAppStore.getState() @@ -179,6 +180,37 @@ function leafIdsInOrder(node: TerminalPaneLayoutNode | null): string[] { : [...leafIdsInOrder(node.first), ...leafIdsInOrder(node.second)] } +/** Main's push after it binds the setup split: the window mirrors it whether or not the reveal lands. */ +function pushSetupSplitSlice( + worktreeId: string, + primaryPtyId: string, + setupPtyId: string, + publishSeq: number +): void { + useAppStore.getState().applyTerminalTopologySlices([ + { + hostId: 'local', + worktreeId, + publishSeq, + revision: 1, + tabs: [{ id: TAB_B, ptyId: primaryPtyId, worktreeId, createdAt: 1 }], + presentation: {}, + layouts: { + [TAB_B]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: PRIMARY_LEAF }, + second: { type: 'leaf', leafId: SETUP_LEAF } + }, + ptyIdsByLeafId: { [PRIMARY_LEAF]: primaryPtyId, [SETUP_LEAF]: setupPtyId } + } + }, + sleeping: {} + } + ]) +} + function setupSplitReveal( requestId: string, worktreeId: string, @@ -238,6 +270,7 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () if (revealSplit) { host.reveal()(setupSplitReveal('reveal-setup-split', worktree.id, setupPtyId)) } + pushSetupSplitSlice(worktree.id, primaryPtyId, setupPtyId, 1) await activateFirstTime(worktree.id) const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] @@ -252,13 +285,11 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () await runScenario(true) }) - // STA-9417: fails until the mirror refactor (B2-4) pushes main's split before the sweep runs. - it.fails('ends with one tab and one owner per terminal when the split reveal never arrives', async () => { + it('ends with one tab and one owner per terminal when the split reveal never arrives', async () => { await runScenario(false) }) - // STA-9417: fails until the mirror refactor (B2-4); the odd runs mint the duplicate. - it.fails('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => { + it('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => { let duplicates = 0 for (let run = 0; run < 8; run++) { const worktree = makeWorktree() @@ -273,6 +304,7 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () if (run % 2 === 0) { host.reveal()(setupSplitReveal(`reveal-${run}`, worktree.id, setupPtyId)) } + pushSetupSplitSlice(worktree.id, primaryPtyId, setupPtyId, run + 1) await activateFirstTime(worktree.id) duplicates += [...ownersByPty(worktree.id).values()].filter( (owners) => owners.length > 1 diff --git a/src/renderer/src/lib/terminal-pane-placement-row.ts b/src/renderer/src/lib/terminal-pane-placement-row.ts index 53ae435a200d..5b8d4fd850d1 100644 --- a/src/renderer/src/lib/terminal-pane-placement-row.ts +++ b/src/renderer/src/lib/terminal-pane-placement-row.ts @@ -12,7 +12,8 @@ export function terminalPanePlacementRow(tab: TerminalTab): TerminalPaneNewTabRo shellOverride, quickCommandLabel, launchAgent, - viewMode + viewMode, + forceHostRuntime } = tab return { title, @@ -24,6 +25,7 @@ export function terminalPanePlacementRow(tab: TerminalTab): TerminalPaneNewTabRo shellOverride, quickCommandLabel, launchAgent, - viewMode + viewMode, + forceHostRuntime } } diff --git a/src/renderer/src/lib/terminal-topology-follow.test.ts b/src/renderer/src/lib/terminal-topology-follow.test.ts new file mode 100644 index 000000000000..62c765b9cc01 --- /dev/null +++ b/src/renderer/src/lib/terminal-topology-follow.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' +import { followTerminalTopology } from './terminal-topology-follow' + +const WT = 'repo::/wt' +const initial = useAppStore.getState() + +function slice(publishSeq: number, tabIds: string[]): TerminalTopologySlice { + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: tabIds.map((id) => ({ id, ptyId: null, worktreeId: WT, createdAt: 1 })), + presentation: {}, + layouts: {}, + sleeping: {} + } +} + +function fakeMain(pulled: () => TerminalTopologySlice[]) { + const calls: string[] = [] + let listener: ((slice: TerminalTopologySlice) => void) | undefined + let releasePull!: () => void + const pullGate = new Promise((resolve) => { + releasePull = resolve + }) + const unsubscribe = vi.fn() + return { + calls, + unsubscribe, + releasePull, + push: (next: TerminalTopologySlice) => listener?.(next), + source: { + onTerminalTopologyChanged: (callback: (next: TerminalTopologySlice) => void) => { + calls.push('subscribe') + listener = callback + return unsubscribe + }, + getTerminalTopologySlices: async () => { + calls.push('pull') + await pullGate + return pulled() + } + } + } +} + +const tabIds = () => (useAppStore.getState().tabsByWorktree[WT] ?? []).map((tab) => tab.id) +const apply = (slices: readonly TerminalTopologySlice[]) => + useAppStore.getState().applyTerminalTopologySlices(slices) + +afterEach(() => { + useAppStore.setState(initial, true) +}) + +describe('followTerminalTopology', () => { + it('subscribes before pulling and resolves only once the pull is applied', async () => { + const main = fakeMain(() => [slice(2, ['a'])]) + let settled = false + const following = followTerminalTopology(main.source, apply, new AbortController().signal).then( + () => { + settled = true + } + ) + await Promise.resolve() + expect(main.calls).toEqual(['subscribe', 'pull']) + expect(settled).toBe(false) + + main.releasePull() + await following + expect(tabIds()).toEqual(['a']) + }) + + it('keeps a push that raced ahead of an older pull', async () => { + const main = fakeMain(() => [slice(2, ['a'])]) + const following = followTerminalTopology(main.source, apply, new AbortController().signal) + main.push(slice(3, ['a', 'b'])) + main.releasePull() + await following + expect(tabIds()).toEqual(['a', 'b']) + }) + + it('ignores a push delivered after a pull that already held a newer slice', async () => { + const main = fakeMain(() => [slice(5, ['a', 'b'])]) + const following = followTerminalTopology(main.source, apply, new AbortController().signal) + main.releasePull() + await following + main.push(slice(4, ['a'])) + expect(tabIds()).toEqual(['a', 'b']) + main.push(slice(6, ['b'])) + expect(tabIds()).toEqual(['b']) + }) + + it('stops following when the boot is aborted', async () => { + const main = fakeMain(() => []) + const abort = new AbortController() + const following = followTerminalTopology(main.source, apply, abort.signal) + main.releasePull() + await following + abort.abort() + expect(main.unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('keeps following pushes when the startup pull fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const main = fakeMain(() => { + throw new Error('ipc down') + }) + const following = followTerminalTopology(main.source, apply, new AbortController().signal) + main.releasePull() + await following + main.push(slice(1, ['a'])) + expect(tabIds()).toEqual(['a']) + expect(warn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/terminal-topology-follow.ts b/src/renderer/src/lib/terminal-topology-follow.ts new file mode 100644 index 000000000000..a5a464f006e0 --- /dev/null +++ b/src/renderer/src/lib/terminal-topology-follow.ts @@ -0,0 +1,29 @@ +import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' + +type TerminalTopologySource = { + onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void + getTerminalTopologySlices: () => Promise +} + +/** + * Subscribes, then pulls: a push sent before the subscription is in the pull, and one racing the + * pull is ordered by publishSeq. Follows pushes until `signal` aborts. + */ +export async function followTerminalTopology( + source: TerminalTopologySource, + apply: (slices: readonly TerminalTopologySlice[]) => void, + signal: AbortSignal +): Promise { + if (signal.aborted) { + return + } + const unsubscribe = source.onTerminalTopologyChanged((slice) => apply([slice])) + signal.addEventListener('abort', unsubscribe, { once: true }) + try { + // One batch, so the startup pull mirrors every worktree in one store update. + apply(await source.getTerminalTopologySlices()) + } catch (error) { + // The hydrated session came from the same store, so the window stays usable; pushes still apply. + console.warn('[terminal-topology] Startup pull failed:', error) + } +} diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index e6dbc3f352e7..13e645f02f4e 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -215,7 +215,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { [folderKey]: 'runtime:env-1' } } - ).written + ) expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} })) expect(patch).toHaveBeenCalledWith( @@ -259,7 +259,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { [folderKey]: 'runtime:stale-env' } } - ).written + ) expect(patch).toHaveBeenCalledTimes(1) expect(patch.mock.calls[0][0]).toEqual( @@ -306,7 +306,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }] } } - ).written + ) expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} })) expect(patch).toHaveBeenCalledWith( @@ -374,7 +374,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { ] } } - ).written + ) expect(patch).toHaveBeenCalledWith( expect.objectContaining({ @@ -484,7 +484,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { }) describe('patchWorkspaceSessionByHost write outcome', () => { - it('rejects `written` when a host partition fails, while the local write still resolves', async () => { + it('rejects when a host partition fails, after the local write resolves', async () => { const folderKey = folderWorkspaceKey('folder-1') const patch = vi.fn(async (_slice: unknown, hostId?: string) => { if (hostId) { @@ -500,8 +500,8 @@ describe('patchWorkspaceSessionByHost write outcome', () => { restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' } } ) - await expect(result.localWrite).resolves.toBeUndefined() - await expect(result.written).rejects.toThrow('runtime down') + await expect(result).rejects.toThrow('runtime down') + expect(patch).toHaveBeenCalledWith(expect.anything()) expect(patch).toHaveBeenCalledWith(expect.anything(), 'runtime:env-1') }) }) @@ -546,7 +546,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { { get: vi.fn(), patch, setSync: vi.fn() }, { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } - ).written + ) expect(patch).toHaveBeenCalledWith( { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, @@ -562,7 +562,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { { get: vi.fn(), patch, setSync: vi.fn() }, { remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } }, { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } - ).written + ) expect(patch).toHaveBeenCalledWith( { remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } }, @@ -583,7 +583,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { [remoteWorktreeId]: [{ id: 'remote-tab', worktreeId: remoteWorktreeId }] } } - ).written + ) expect(patch).toHaveBeenCalledWith( { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, @@ -603,7 +603,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } - ).written + ) expect(patch).toHaveBeenCalledTimes(1) expect(patch).toHaveBeenCalledWith( diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 909acdd4d69f..540e3173a0d0 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -66,6 +66,11 @@ type DurableSessionApi = SessionApi & { flush: () => Promise } +export type WorkspaceSessionHostPatch = { + patch: WorkspaceSessionPatch + hostId?: ExecutionHostId +} + export type WorkspaceSessionHostSnapshot = { state: WorkspaceSessionState hostId?: ExecutionHostId @@ -231,33 +236,40 @@ function splitWorkspaceSessionForWrite( return slices } -/** Patch path of the debounced session writer: split the partial patch by owner host and patch - * each partition. `localWrite` orders the SSH remote-workspace upload; `written` settles once - * every partition did and rejects if any failed, so the writer re-queues those fields. */ +/** A partial patch split by owner host, local first. */ +export function buildWorkspaceSessionHostPatches( + patch: WorkspaceSessionPatch, + state: HostPersistenceState +): WorkspaceSessionHostPatch[] { + const slices = splitWorkspaceSessionForWrite(patch as WorkspaceSessionState, state, 'patch') + return [ + { patch: slices[LOCAL_EXECUTION_HOST_ID] ?? patch }, + ...nonLocalHostSessionEntries(slices).map(([hostId, slice]) => ({ hostId, patch: slice })) + ] +} + +/** Patch path of the debounced session writer: patch each owner partition. Settles once every + * partition did and rejects if any failed, so the writer re-queues those fields. */ export function patchWorkspaceSessionByHost( api: SessionApi, patch: WorkspaceSessionPatch, state: HostPersistenceState -): { localWrite: Promise; written: Promise } { - const slices = splitWorkspaceSessionForWrite(patch as WorkspaceSessionState, state, 'patch') - const local = (slices[LOCAL_EXECUTION_HOST_ID] ?? patch) as WorkspaceSessionPatch - const localWrite = api.patch(local) - const hostWrites = nonLocalHostSessionEntries(slices).map(([hostId, slice]) => - api.patch(slice, hostId).catch((err: unknown) => { - console.warn(`[session] host partition patch failed for ${hostId}:`, err) - throw err - }) +): Promise { + const writes = buildWorkspaceSessionHostPatches(patch, state).map(({ hostId, patch: slice }) => + hostId + ? api.patch(slice, hostId).catch((err: unknown) => { + console.warn(`[session] host partition patch failed for ${hostId}:`, err) + throw err + }) + : api.patch(slice) ) // Why allSettled: every partition finishes before a retry rewrites the same fields. - const written = Promise.allSettled([localWrite, ...hostWrites]).then((results) => { + return Promise.allSettled(writes).then((results) => { const failure = results.find((result) => result.status === 'rejected') if (failure) { throw failure.reason } }) - // Why: a host failure must not surface as an unhandled rejection on the upload chain's promise. - localWrite.catch(() => {}) - return { localWrite, written } } /** Persist a fresh full snapshot to every owning host partition, then force the diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts b/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts index 26a3545f2295..cafb2bffe9b6 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts @@ -95,7 +95,7 @@ describe('ssh folder workspace partition ownership', () => { }, { tabsByWorktree: { [FOLDER_KEY]: [tab('tab-desktop', FOLDER_KEY)] } }, state - ).written + ) // Main applies a patch field-wise ({ ...current, ...patch }), so a `tabsByWorktree` written to // `ssh:` WITHOUT the folder row replaces the row main put there. @@ -313,7 +313,7 @@ describe('a bare workspace id two partitions both hold', () => { contestedHostWorkspaceSessions: read.contestedHostWorkspaceSessions, contestedPrimaryHostBySessionKey: read.contestedPrimaryHostBySessionKey } - ).written + ) // Main applies a patch field-wise, so an `ssh:` write carrying only the sibling would // erase the declined row from the one partition that still holds it. Declining to show a row diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index ee54c4e32cb6..268bd60dffc8 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -114,12 +114,6 @@ function testDeps(args: { } terminalLayoutsByTabId[tabId] = { ...layout, ptyIdsByLeafId: { ...layout.ptyIdsByLeafId } } }) - const replaceTerminalLayoutPanePtyId = vi.fn((tabId: string, leafId: string, ptyId: string) => { - const layout = terminalLayoutsByTabId[tabId] - if (layout) { - layout.ptyIdsByLeafId[leafId] = ptyId - } - }) let createdCount = 0 const createTab: TerminalSlice['createTab'] = vi.fn((worktreeId, _group, _shell, options) => { createdCount += 1 @@ -154,7 +148,6 @@ function testDeps(args: { sleeping.map((record) => [record.paneKey, record]) ), updateTabPtyId, - replaceTerminalLayoutPanePtyId, terminalLayoutsByTabId, unifiedTabsByWorktree: { [WORKTREE_ID]: args.structured @@ -288,13 +281,24 @@ describe('worktree agent activation gate', () => { sessions: [listed(ptyId)], surfaceOwners: new Map([[ptyId, UNOWNED]]) }) + const bindTerminalLeaf = vi.fn(async () => ({ status: 'bound' as const })) + vi.stubGlobal('window', { api: { session: { bindTerminalLeaf } } }) - await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + try { + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + } finally { + vi.unstubAllGlobals() + } - expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { - initialPtyId: ptyId, - activate: false, - recordInteraction: false + expect(createTab).toHaveBeenCalledOnce() + // Main binds the minted pane at once, so a restart before it mounts reattaches the agent. + const [minted] = deps.getState().tabsByWorktree[WORKTREE_ID] + const leafId = deps.getState().terminalLayoutsByTabId[minted.id]?.activeLeafId + expect(bindTerminalLeaf).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WORKTREE_ID, + tabId: minted.id, + leafId, + ptyId }) expect(resume).not.toHaveBeenCalled() }) @@ -318,13 +322,14 @@ describe('worktree agent activation gate', () => { expect(createTab).toHaveBeenCalledOnce() expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + initialLeafId: expect.any(String), initialPtyId: livePtyId, activate: false, recordInteraction: false }) }) - it('rebinds an unowned PTY to the recorded pane this renderer still holds', async () => { + it('rebinds an unowned PTY to the recorded pane of a split this renderer still holds', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` const recorded = { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId, tabId: 'tab-live' } const { deps, createTab, resume } = testDeps({ @@ -332,21 +337,75 @@ describe('worktree agent activation gate', () => { surfaceOwners: new Map([[ptyId, { unowned: true, recorded }]]) }) seedExistingSurface(deps.getState(), { tabId: 'tab-live', leafId: LIVE_LEAF_ID }) + deps.getState().terminalLayoutsByTabId['tab-live'] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: SIBLING_LEAF_ID }, + second: { type: 'leaf', leafId: LIVE_LEAF_ID } + }, + activeLeafId: SIBLING_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SIBLING_LEAF_ID]: `${WORKTREE_ID}@@shell` } + } + const bindTerminalLeaf = vi.fn(async () => ({ status: 'bound' as const })) + vi.stubGlobal('window', { api: { session: { bindTerminalLeaf } } }) - await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + try { + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + } finally { + vi.unstubAllGlobals() + } // The host's graph omits unmounted panes; minting here forked the agent onto a second tab. expect(createTab).not.toHaveBeenCalled() - expect(deps.getState().terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({ - [LIVE_LEAF_ID]: ptyId - }) + // This window records the live attachment; main records the pane's binding, so the pane + // reattaches instead of starting a new shell. expect(deps.getState().ptyIdsByTabId['tab-live']).toEqual([ptyId]) + expect(bindTerminalLeaf).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WORKTREE_ID, + tabId: 'tab-live', + leafId: LIVE_LEAF_ID, + ptyId + }) expect(resume).not.toHaveBeenCalled() }) + // Main keeps an exited pane's binding (R17); a PTY the host no longer lists holds nothing. + it('rebinds an unowned PTY to its recorded pane over the binding of an exited process', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const recorded = { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId, tabId: 'tab-live' } + const { deps, createTab } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, { unowned: true, recorded }]]) + }) + seedExistingSurface(deps.getState(), { + tabId: 'tab-live', + leafId: LIVE_LEAF_ID, + boundPtyId: `${WORKTREE_ID}@@exited-pty` + }) + const bindTerminalLeaf = vi.fn(async () => ({ status: 'bound' as const })) + vi.stubGlobal('window', { api: { session: { bindTerminalLeaf } } }) + + try { + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + } finally { + vi.unstubAllGlobals() + } + + expect(createTab).not.toHaveBeenCalled() + expect(deps.getState().ptyIdsByTabId['tab-live']).toEqual([ptyId]) + expect(bindTerminalLeaf).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WORKTREE_ID, + tabId: 'tab-live', + leafId: LIVE_LEAF_ID, + ptyId + }) + }) + it.each<[string, (store: GateTestStore) => void]>([ [ - 'the recorded pane now holds another PTY', + 'the recorded pane now holds another live PTY', (store) => seedExistingSurface(store, { tabId: 'tab-live', @@ -371,10 +430,14 @@ describe('worktree agent activation gate', () => { ] ])('mints a tab for an unowned PTY when %s', async (_case, seed) => { const ptyId = `${WORKTREE_ID}@@live-pty` + const otherPtyId = `${WORKTREE_ID}@@other-pty` const recorded = { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId, tabId: 'tab-live' } const { deps, createTab } = testDeps({ sessions: [listed(ptyId)], - surfaceOwners: new Map([[ptyId, { unowned: true, recorded }]]) + surfaceOwners: new Map([ + [ptyId, { unowned: true, recorded }], + [otherPtyId, { unowned: true, recorded: null }] + ]) }) seed(deps.getState()) const recordedLayoutBefore = structuredClone(deps.getState().terminalLayoutsByTabId['tab-live']) @@ -382,6 +445,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + initialLeafId: expect.any(String), initialPtyId: ptyId, activate: false, recordInteraction: false @@ -402,6 +466,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(folderWorkspaceId, deps)).resolves.toBe('adopted') expect(createTab).toHaveBeenCalledWith(folderWorkspaceId, undefined, undefined, { + initialLeafId: expect.any(String), initialPtyId: ptyId, activate: false, recordInteraction: false @@ -613,11 +678,6 @@ describe('worktree agent activation gate', () => { expect(createTab).not.toHaveBeenCalled() expect(store.updateTabPtyId).toHaveBeenCalledWith('tab-live', livePtyId) - expect(store.replaceTerminalLayoutPanePtyId).toHaveBeenCalledWith( - 'tab-live', - LIVE_LEAF_ID, - livePtyId - ) }) it('materializes the host tab id for a surface this renderer never mounted', async () => { @@ -736,9 +796,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).not.toHaveBeenCalled() expect(resume).not.toHaveBeenCalled() - expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({ - [LIVE_LEAF_ID]: livePtyId - }) + expect(store.ptyIdsByTabId['tab-live']).toEqual([livePtyId]) } ) @@ -759,9 +817,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).not.toHaveBeenCalled() - expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({ - [LIVE_LEAF_ID]: livePtyId - }) + expect(store.ptyIdsByTabId['tab-live']).toEqual([livePtyId]) }) it('re-reads local ownership after the census before minting', async () => { @@ -859,6 +915,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + initialLeafId: expect.any(String), initialPtyId: livePtyId, activate: false, recordInteraction: false diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 33df753468dc..a32eae97774f 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -1,6 +1,7 @@ import { parsePaneKey } from '../../../shared/stable-pane-id' import { worktreeIdsEqual } from '../../../shared/worktree/id' import type { useAppStore } from '@/store' +import { createBrowserUuid } from '@/lib/browser-uuid' import { resolveTerminalTabPtyOwnership } from './terminal-tab-for-pty-id' import type { LiveTerminalSurfaceOwner, @@ -15,7 +16,6 @@ export type LiveSurfaceAdoptionStore = Pick< | 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'updateTabPtyId' - | 'replaceTerminalLayoutPanePtyId' > function layoutContainsLeaf( @@ -58,17 +58,39 @@ export function bindLivePtyToExactSurface( return false } store.updateTabPtyId(terminal.tabId, terminal.ptyId) - store.replaceTerminalLayoutPanePtyId(terminal.tabId, pane.leafId, terminal.ptyId) + bindLeafInMain(existing.ownerWorktreeId, terminal.tabId, pane.leafId, terminal.ptyId) return true } + return mintTabForLivePty(store, worktreeId, terminal.ptyId, { + tabId: terminal.tabId, + leafId: pane.leafId + }) +} + +/** The pane's binding is main's: it records the adopted PTY, and its push brings it to the pane. */ +function bindLeafInMain(worktreeId: string, tabId: string, leafId: string, ptyId: string): void { + void globalThis.window?.api?.session?.bindTerminalLeaf?.({ worktreeId, tabId, leafId, ptyId }) +} + +/** A new tab for a live PTY; bound in main at once, so a restart before its pane mounts reattaches. */ +function mintTabForLivePty( + store: LiveSurfaceAdoptionStore, + worktreeId: string, + ptyId: string, + { tabId, leafId }: { tabId?: string; leafId: string } +): boolean { const created = store.createTab(worktreeId, undefined, undefined, { - id: terminal.tabId, - initialLeafId: pane.leafId, - initialPtyId: terminal.ptyId, + ...(tabId ? { id: tabId } : {}), + initialLeafId: leafId, + initialPtyId: ptyId, activate: false, recordInteraction: false }) - return created.id === terminal.tabId + if (tabId && created.id !== tabId) { + return false + } + bindLeafInMain(worktreeId, created.id, leafId, ptyId) + return true } function tabExists(store: LiveSurfaceAdoptionStore, tabId: string): boolean { @@ -83,14 +105,16 @@ function tabExists(store: LiveSurfaceAdoptionStore, tabId: string): boolean { function bindToRecordedSurface( store: LiveSurfaceAdoptionStore, worktreeId: string, - recorded: LiveTerminalSurfaceOwner + recorded: LiveTerminalSurfaceOwner, + liveSurfaceOwners: LiveTerminalSurfaceOwnerIndex ): boolean { const pane = parsePaneKey(recorded.paneKey) if (!pane || !tabExists(store, recorded.tabId)) { return false } const heldPtyId = store.terminalLayoutsByTabId[recorded.tabId]?.ptyIdsByLeafId?.[pane.leafId] - if (heldPtyId && heldPtyId !== recorded.ptyId) { + // Main keeps an exited pane's binding (R17), so only a PTY the host still lists holds the pane. + if (heldPtyId && heldPtyId !== recorded.ptyId && liveSurfaceOwners.has(heldPtyId)) { return false } return bindLivePtyToExactSurface(store, worktreeId, recorded) @@ -161,11 +185,12 @@ export async function adoptLiveWorkspacePtySurfaces( if (unbound.length === 0) { return { surfaced, declinedPtyIds } } - let surfaceOwners: LiveTerminalSurfaceOwnerIndex | null + // An unreadable census lists nothing, so it proves no PTY unowned. + let surfaceOwners: LiveTerminalSurfaceOwnerIndex try { - surfaceOwners = await listSurfaceOwners(worktreeId) + surfaceOwners = (await listSurfaceOwners(worktreeId)) ?? new Map() } catch { - surfaceOwners = null + surfaceOwners = new Map() } const materializedTabIds = new Set() for (const ptyId of unbound) { @@ -175,7 +200,7 @@ export async function adoptLiveWorkspacePtySurfaces( surfaced = true continue } - const owner = surfaceOwners?.get(ptyId) + const owner = surfaceOwners.get(ptyId) // Why: only the execution host can prove a live PTY is unowned, and minting // on anything weaker forks a running agent onto a second empty surface. if (!owner) { @@ -191,14 +216,13 @@ export async function adoptLiveWorkspacePtySurfaces( continue } surfaced = true - if (owner.recorded && bindToRecordedSurface(getState(), worktreeId, owner.recorded)) { + if ( + owner.recorded && + bindToRecordedSurface(getState(), worktreeId, owner.recorded, surfaceOwners) + ) { continue } - getState().createTab(worktreeId, undefined, undefined, { - initialPtyId: ptyId, - activate: false, - recordInteraction: false - }) + mintTabForLivePty(getState(), worktreeId, ptyId, { leafId: createBrowserUuid() }) } return { surfaced, declinedPtyIds } } diff --git a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts index 3fa6692eeee6..e7d3b5f8683c 100644 --- a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts +++ b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import { collectLeafIds } from '../components/terminal-pane/terminal-pane-layout-tree' -import { - planTerminalLiveLayoutRemovals, - selectRetiredPaneIds, - trackRetiredLeafIds -} from '../components/terminal-pane/terminal-live-layout-reconciliation' +import { collectLeafIds } from '../../../shared/terminal-pane-layout-tree' +import { planTerminalLiveLayoutRemovals } from '../components/terminal-pane/terminal-live-layout-reconciliation' import { applyFreshWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' import { clearWebSessionTerminalOrphanRecoveryForTests, @@ -61,29 +57,19 @@ function retiredSnapshot(): RuntimeMobileSessionTabsResult { function createReconciliation() { let state = makeState() - let previousLayoutLeafIds: ReadonlySet = new Set() - let retiredLeafIds: ReadonlySet = new Set() const mounted = new Set(mountedLeaves) const call = vi.fn(async () => { throw new Error('execution host unavailable') }) - function plan(secondPtyId: string | null): number[] { + function plan(secondPending = false): string[] { const root = state.terminalLayoutsByTabId[TAB_ID]?.root expect(root).toBeDefined() - const layoutLeafIds = new Set(root ? collectLeafIds(root) : []) - retiredLeafIds = trackRetiredLeafIds({ - retiredLeafIds, - previousLayoutLeafIds, - layoutLeafIds, - mountedLeafIds: mounted - }) - previousLayoutLeafIds = layoutLeafIds - return selectRetiredPaneIds(planTerminalLiveLayoutRemovals(root, mounted, retiredLeafIds), { - paneCount: mounted.size, - paneIdForLeaf: (leaf) => (leaf === LEAF_ID ? 1 : 2), - ptyIdForPane: (pane) => (pane === 1 ? 'remote:first' : secondPtyId) - }) + return planTerminalLiveLayoutRemovals( + root, + mounted, + new Set(secondPending ? [SECOND_LEAF_ID] : []) + ) } return { @@ -109,49 +95,104 @@ describe('host snapshot fences before split-pane retirement', () => { clearWebSessionTerminalOrphanRecoveryForTests() }) - it('keeps a null-transport pane when an older layout arrives', async () => { + it('keeps the pane when an older layout arrives', async () => { const view = createReconciliation() await view.receive(snapshot(2)) - expect(view.plan('remote:second')).toEqual([]) + expect(view.plan()).toEqual([]) await view.receive(retiredSnapshotWithVersion(1)) expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) + expect(view.plan()).toEqual([]) }) it('retains a missing pane when a newer layout cannot verify the host inventory', async () => { const view = createReconciliation() await view.receive(snapshot(2)) - view.plan('remote:second') + view.plan() await view.receive(snapshot(3, [LEAF_ID])) expect(view.call).toHaveBeenCalled() expect(view.leaves()).toContain(SECOND_LEAF_ID) - expect(view.plan(null)).toEqual([]) + expect(view.plan()).toEqual([]) }) - it('defers proven retirement until the transport clears and does not close twice', async () => { + it('defers proven retirement while the pane is pending and does not detach twice', async () => { const view = createReconciliation() await view.receive(snapshot(2)) - view.plan('remote:second') + view.plan() await view.receive(retiredSnapshot()) expect(view.leaves()).toEqual([LEAF_ID]) - expect(view.plan('remote:second')).toEqual([]) - expect(view.plan(null)).toEqual([2]) + expect(view.plan(true)).toEqual([]) + expect(view.plan()).toEqual([SECOND_LEAF_ID]) view.removeSecond() - expect(view.plan(null)).toEqual([]) + expect(view.plan()).toEqual([]) }) it('clears deferred retirement when the host reintroduces the leaf before detach', async () => { const view = createReconciliation() await view.receive(snapshot(2)) - view.plan('remote:second') + view.plan() await view.receive(retiredSnapshot()) - expect(view.plan('remote:second')).toEqual([]) + expect(view.plan(true)).toEqual([]) await view.receive(snapshot(4)) expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) + expect(view.plan()).toEqual([]) }) }) function retiredSnapshotWithVersion(snapshotVersion: number): RuntimeMobileSessionTabsResult { return { ...retiredSnapshot(), snapshotVersion } } + +describe('a host snapshot that does not list a pane split here yet', () => { + beforeEach(() => { + resetWebSessionTabsSyncTestState() + clearWebSessionTerminalOrphanRecoveryForTests() + }) + + it('keeps the pane while the host has not recorded it', async () => { + let state = makeState() + // The host answers for a pane it has not recorded yet. + const call = vi.fn(async () => ({ + id: 'resolve', + ok: false as const, + error: { code: 'terminal_not_found', message: 'terminal_not_found' } + })) + const receive = async (incoming: RuntimeMobileSessionTabsResult): Promise => { + const recovered = await recoverWebSessionTerminalOrphansBeforeApply(state, incoming, ENV, { + call + }) + expect(recovered).not.toBeNull() + if (recovered) { + state = { ...state, ...applyFreshWebSessionTabsSnapshot(state, recovered, ENV) } + } + } + await receive(snapshot(2, [LEAF_ID])) + // The split writes the window's tree before any later host frame can apply. + state = { + ...state, + terminalLayoutsByTabId: { + ...state.terminalLayoutsByTabId, + [TAB_ID]: { + ...state.terminalLayoutsByTabId[TAB_ID], + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + } + } + } + } + const later = snapshot(3, [LEAF_ID]) + await receive({ ...later, tabs: later.tabs.map((tab) => ({ ...tab, title: 'vim' })) }) + + expect(call).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.resolvePane' })) + expect(collectLeafIds(state.terminalLayoutsByTabId[TAB_ID].root!)).toEqual(mountedLeaves) + expect( + planTerminalLiveLayoutRemovals( + state.terminalLayoutsByTabId[TAB_ID].root, + mountedLeaves, + new Set() + ) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts index aacb445bbe3f..c387a8345906 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -1,6 +1,6 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' -import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { normalizeTerminalLayoutPtyOwnership } from '../../../../shared/terminal-layout-pty-ownership' import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner' import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner' import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from '../runtime-terminal-stream' diff --git a/src/renderer/src/startup/startup-degraded-recovery.test.ts b/src/renderer/src/startup/startup-degraded-recovery.test.ts index a3f0fb44f261..7886abfe23e7 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.test.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.test.ts @@ -11,7 +11,8 @@ vi.mock('../store', async () => { const useAppStore = create(() => ({ startupWorktreeRefreshCompleted: false, terminalStartupRestorationReady: false, - workspaceSessionReady: false + workspaceSessionReady: false, + applyTerminalTopologySlices: vi.fn() })) return { useAppStore } }) @@ -22,17 +23,21 @@ import { recoverFromDegradedStartup } from './startup-degraded-recovery' function recover(args: { isCancelled: () => boolean reconnectPersistedTerminals: () => Promise + terminalTopologyFollowed?: boolean }): Promise { return recoverFromDegradedStartup({ error: new Error('hydration failed'), uiHydrated: true, reconnectStarted: false, + terminalTopologyFollowed: true, hydratePersistedUI: vi.fn(), abortSignal: new AbortController().signal, ...args }) } +const SLICE = { worktreeId: 'repo::/wt', publishSeq: 3 } + describe('recoverFromDegradedStartup', () => { beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => undefined) @@ -44,6 +49,10 @@ describe('recoverFromDegradedStartup', () => { awaitFirstWindowStartupServices: async () => undefined, recoverLegacyWorkerTerminalsForRendererStartup: async () => undefined, relaunch: vi.fn() + }, + session: { + onTerminalTopologyChanged: vi.fn(() => () => undefined), + getTerminalTopologySlices: async () => [SLICE] } } }) @@ -71,4 +80,24 @@ describe('recoverFromDegradedStartup', () => { expect(useAppStore.getState().terminalStartupRestorationReady).toBe(false) }) + + it("follows main's terminal topology when startup failed before the follow began", async () => { + await recover({ + isCancelled: () => false, + reconnectPersistedTerminals: async () => undefined, + terminalTopologyFollowed: false + }) + + expect(window.api.session.onTerminalTopologyChanged).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().applyTerminalTopologySlices).toHaveBeenCalledWith([SLICE]) + }) + + it('does not follow twice when the success path already follows', async () => { + await recover({ + isCancelled: () => false, + reconnectPersistedTerminals: async () => undefined + }) + + expect(window.api.session.onTerminalTopologyChanged).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/startup/startup-degraded-recovery.ts b/src/renderer/src/startup/startup-degraded-recovery.ts index c5570af27173..0f0275240bd1 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' import { getStartupErrorFallbackUI } from '../lib/startup-ui-hydration' +import { followTerminalTopology } from '../lib/terminal-topology-follow' import { collectTerminalProviderSnapshotPtyIds, refreshTerminalProviderSnapshotCapabilities @@ -13,6 +14,8 @@ type DegradedStartupRecoveryArgs = { uiHydrated: boolean /** Whether the success path already began terminal reconnect over now-partial state. */ reconnectStarted: boolean + /** Whether the success path already follows main's terminal topology. */ + terminalTopologyFollowed: boolean isCancelled: () => boolean hydratePersistedUI: ReturnType['hydratePersistedUI'] reconnectPersistedTerminals: (signal: AbortSignal) => Promise @@ -44,6 +47,7 @@ export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryAr error, uiHydrated, reconnectStarted, + terminalTopologyFollowed, isCancelled, hydratePersistedUI, reconnectPersistedTerminals, @@ -85,6 +89,14 @@ export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryAr forceWorkspaceSessionReady() return } + // Why: without main's pushes no pending terminal change settles, so the session stays stuck. + if (!terminalTopologyFollowed) { + await followTerminalTopology( + window.api.session, + useAppStore.getState().applyTerminalTopologySlices, + abortSignal + ) + } try { await window.api.app.awaitFirstWindowStartupServices() await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() diff --git a/src/renderer/src/startup/startup-runtime-session-hosts.ts b/src/renderer/src/startup/startup-runtime-session-hosts.ts new file mode 100644 index 000000000000..dc071166e050 --- /dev/null +++ b/src/renderer/src/startup/startup-runtime-session-hosts.ts @@ -0,0 +1,12 @@ +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' + +export async function listRuntimeSessionHostIdsForStartup(): Promise { + try { + return (await window.api.runtimeEnvironments.list()).map((environment) => + toRuntimeExecutionHostId(environment.id) + ) + } catch (err) { + console.warn('Failed to list runtime session hosts for startup:', err) + return [] + } +} diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts index 2001f6587523..59b40459c128 100644 --- a/src/renderer/src/store/slices/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -27,7 +27,7 @@ export function createAgentStatusAuthorityActions( | 'restoreAgentPaneAuthority' | 'transferAgentPaneAuthority' > { - const { get, set, freshness } = runtime + const { get, set, setCommittingSleepingRecords, freshness } = runtime return { scheduleAgentStatusFreshness: () => freshness.schedule(), @@ -50,7 +50,7 @@ export function createAgentStatusAuthorityActions( rendererAgentStatusObservations.forget(key) } let hadLive = false - set((s) => { + setCommittingSleepingRecords((s) => { const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey) hadLive = retiredLivePaneKeys.length > 0 let nextRetentionSuppressedPaneKeys = removePaneKeys( diff --git a/src/renderer/src/store/slices/agent-status-launch-actions.ts b/src/renderer/src/store/slices/agent-status-launch-actions.ts index 4d13ea57d675..c32ebf355197 100644 --- a/src/renderer/src/store/slices/agent-status-launch-actions.ts +++ b/src/renderer/src/store/slices/agent-status-launch-actions.ts @@ -16,10 +16,10 @@ export function createAgentStatusLaunchActions( AgentStatusSlice, 'registerAgentLaunchConfig' | 'getAgentLaunchConfigForStatusEntry' | 'clearAgentLaunchConfig' > { - const { get, set } = runtime + const { get, set, setCommittingSleepingRecords } = runtime return { registerAgentLaunchConfig: (paneKey, launchConfig, metadata) => { - set((s) => { + setCommittingSleepingRecords((s) => { const copiedLaunchConfig = copyLaunchConfig(launchConfig) const nextRegistryEntry: AgentLaunchConfigRegistryEntry = { launchConfig: copiedLaunchConfig, diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts index 4bfbe8701305..87198cf72193 100644 --- a/src/renderer/src/store/slices/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -35,7 +35,7 @@ export function createAgentStatusLiveActions( ): Pick { const { get, - set, + setCommittingSleepingRecords, applyGeneratedTabTitleUpdate, freshness, requestFreshness, @@ -68,7 +68,7 @@ export function createAgentStatusLiveActions( } let built: AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null = null let liveEntryDelta: FreshnessLiveEntryDelta | null = null - set((state) => { + setCommittingSleepingRecords((state) => { const retirement = state.recentlyRetiredAgentStatusPaneKeys[paneKey] if ( (retirement !== undefined && diff --git a/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts index 233a7c0121b6..207befd616b9 100644 --- a/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts +++ b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts @@ -39,6 +39,7 @@ function setup(existing: AgentStatusEntry) { requestFreshness, transactAgentStatuses: vi.fn() } as unknown as AgentStatusRuntime + runtime.setCommittingSleepingRecords = runtime.set return { requestFreshness, actions: createAgentStatusLiveActions(runtime) } } diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 8deddac21be8..c2c22dfa5bc7 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -26,7 +26,7 @@ import { agentVerdictFields } from '../../../../shared/agent-main-agent-verdict' export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime ): Pick { - const { get, set, requestFreshness } = runtime + const { get, setCommittingSleepingRecords, requestFreshness } = runtime return { recordAgentProviderSession: ( paneKey: string, @@ -49,7 +49,7 @@ export function createAgentStatusProviderSessionActions( return } let removedLiveStatus = false - set((s) => { + setCommittingSleepingRecords((s) => { const existingStatus = s.agentStatusByPaneKey[paneKey] const existingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] if ( diff --git a/src/renderer/src/store/slices/agent-status-recovery-actions.ts b/src/renderer/src/store/slices/agent-status-recovery-actions.ts index 0e256834a9f7..0b24563457ba 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -25,10 +25,10 @@ export function createAgentStatusRecoveryActions( | 'clearSleepingAgentSessionsByWorktree' | 'pruneSleepingAgentSessions' > { - const { set, clearSleepingAgentSessionsByPaneKey } = runtime + const { setCommittingSleepingRecords, clearSleepingAgentSessionsByPaneKey } = runtime return { captureSleepingAgentSessionsByWorktree: (worktreeId, paneKeys) => { - set((s) => { + setCommittingSleepingRecords((s) => { const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, { paneKeys, captureMode: 'manual-worktree-sleep' @@ -52,7 +52,7 @@ export function createAgentStatusRecoveryActions( }, captureAllSleepingAgentSessions: (mode) => { - set((s) => { + setCommittingSleepingRecords((s) => { const capturedAt = Date.now() const origin = mode === 'quit' ? ('quit' as const) : ('live' as const) const next: Record = { @@ -109,7 +109,7 @@ export function createAgentStatusRecoveryActions( clearSleepingAgentSessionsByPaneKey, clearSleepingAgentSessionsByWorktree: (worktreeId) => { - set((s) => { + setCommittingSleepingRecords((s) => { let changed = false const next: Record = {} const removed: string[] = [] @@ -139,7 +139,7 @@ export function createAgentStatusRecoveryActions( }, pruneSleepingAgentSessions: (validWorktreeIds) => { - set((s) => { + setCommittingSleepingRecords((s) => { let changed = false const next: Record = {} const removed: string[] = [] diff --git a/src/renderer/src/store/slices/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status-runtime.ts index 0716fa633aa2..35cea24a4eea 100644 --- a/src/renderer/src/store/slices/agent-status-runtime.ts +++ b/src/renderer/src/store/slices/agent-status-runtime.ts @@ -4,6 +4,7 @@ import type { AgentStatusBatchTransaction, AgentStatusBatchUpdate } from './agen import type { AgentStatusSlice } from './agent-status-slice-contract' import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' +import { commitSleepingRecordWrite } from '../terminals/terminal-sleeping-record-commits' export type AgentStatusStateUpdate = | AppState @@ -13,6 +14,8 @@ export type AgentStatusStateUpdate = export type AgentStatusRuntime = { get: () => AppState set: (update: AgentStatusStateUpdate) => void + /** `set`, then commits this window's sleeping-record change to main. */ + setCommittingSleepingRecords: (update: AgentStatusStateUpdate) => void runAfterCommit: (effect: () => void) => void applyGeneratedTabTitleUpdate: (update: GeneratedTabTitleUpdate) => void requestFreshness: (acceptedInBatch: boolean) => void @@ -61,6 +64,9 @@ export function createAgentStatusRuntime( Object.assign(staged, nextState) } + const setCommittingSleepingRecords = (update: AgentStatusStateUpdate): void => + commitSleepingRecordWrite(get, () => set(update)) + const runAfterCommit = (effect: () => void): void => { if (batchedAgentStatusEffects) { batchedAgentStatusEffects.push(effect) @@ -187,7 +193,7 @@ export function createAgentStatusRuntime( return } const uniquePaneKeys = new Set(paneKeys) - set((s) => { + setCommittingSleepingRecords((s) => { let nextSleeping = s.sleepingAgentSessionsByPaneKey let nextLaunchConfigs = s.agentLaunchConfigByPaneKey for (const paneKey of uniquePaneKeys) { @@ -220,6 +226,7 @@ export function createAgentStatusRuntime( return { get, set, + setCommittingSleepingRecords, runAfterCommit, applyGeneratedTabTitleUpdate, requestFreshness, diff --git a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts index 7f9042db583f..a302c27863bc 100644 --- a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts +++ b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts @@ -230,34 +230,3 @@ describe('codex restart notices with colliding account labels', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId).toBe(dismissed) }) }) - -describe('replaceTerminalLayoutPanePtyId', () => { - const LEAF = '11111111-1111-4111-8111-111111111111' - - it('rebinds one leaf and leaves split siblings alone', () => { - const OTHER_LEAF = '22222222-2222-4222-8222-222222222222' - useAppStore.setState({ - terminalLayoutsByTabId: { - 'tab-1': { - root: null, - activeLeafId: LEAF, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF]: 'pty-old', [OTHER_LEAF]: 'pty-sibling' } - } - } - }) - - useAppStore.getState().replaceTerminalLayoutPanePtyId('tab-1', LEAF, 'pty-new') - - expect(useAppStore.getState().terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ - [LEAF]: 'pty-new', - [OTHER_LEAF]: 'pty-sibling' - }) - }) - - it('does nothing for a tab with no layout', () => { - const before = useAppStore.getState().terminalLayoutsByTabId - useAppStore.getState().replaceTerminalLayoutPanePtyId('tab-none', LEAF, 'pty-new') - expect(useAppStore.getState().terminalLayoutsByTabId).toBe(before) - }) -}) diff --git a/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts b/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts index 651e30bb9776..aeface581b6a 100644 --- a/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts +++ b/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts @@ -89,7 +89,7 @@ async function saveDebouncedPatch(api: ReturnType) terminalLayoutsByTabId: payload.terminalLayoutsByTabId }, state - ).written + ) } async function saveQuitSnapshot(api: ReturnType) { diff --git a/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts b/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts index d0befefa537a..1e9dbc93b28f 100644 --- a/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts +++ b/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts @@ -74,14 +74,14 @@ describe('purge stops routing session writes to the removed runtime partition', } const patch = { activeWorktreeIdsOnShutdown: [WT_A] } as unknown as WorkspaceSessionPatch - await patchWorkspaceSessionByHost(api, patch, store.getState()).written + await patchWorkspaceSessionByHost(api, patch, store.getState()) const runtimeWritesBefore = api.patch.mock.calls.filter(([, hostId]) => hostId === RUNTIME_A) expect(runtimeWritesBefore).toHaveLength(1) store.getState().purgeStaleRuntimeHostState(['env-a']) api.patch.mockClear() - await patchWorkspaceSessionByHost(api, patch, store.getState()).written + await patchWorkspaceSessionByHost(api, patch, store.getState()) const runtimeWritesAfter = api.patch.mock.calls.filter(([, hostId]) => hostId === RUNTIME_A) expect(runtimeWritesAfter).toHaveLength(0) // The local write still happens. diff --git a/src/renderer/src/store/slices/store-cascades-test-harness.ts b/src/renderer/src/store/slices/store-cascades-test-harness.ts index 5e731f934846..73c182b4b9b3 100644 --- a/src/renderer/src/store/slices/store-cascades-test-harness.ts +++ b/src/renderer/src/store/slices/store-cascades-test-harness.ts @@ -15,6 +15,7 @@ export type StoreCascadesMockApi = { settings: { get: Mock; set: Mock } runtimeEnvironments: { call: Mock } cache: { getGitHub: Mock; setGitHub: Mock } + session: { commitTerminalSleepingRecords: Mock } } /** window.api double shared by the store cascade suites; installs itself on globalThis. */ @@ -51,6 +52,9 @@ export function createStoreCascadesMockApi(): StoreCascadesMockApi { cache: { getGitHub: vi.fn().mockResolvedValue(null), setGitHub: vi.fn().mockResolvedValue(undefined) + }, + session: { + commitTerminalSleepingRecords: vi.fn().mockResolvedValue(undefined) } } diff --git a/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts b/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts index 11bba797aac3..05450cba8c3e 100644 --- a/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts +++ b/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts @@ -270,6 +270,13 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { providerSession: { key: 'session_id', id: 'sess-rollback-1' } }) expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined() + // Main got the sleep, then the rollback's record. + expect(mockApi.session.commitTerminalSleepingRecords.mock.calls.at(-2)?.[0].sleep).toEqual({ + [targetPaneKey]: expect.objectContaining({ origin: 'worktree-sleep' }) + }) + expect(mockApi.session.commitTerminalSleepingRecords.mock.calls.at(-1)?.[0].sleep).toEqual({ + [targetPaneKey]: state.sleepingAgentSessionsByPaneKey[targetPaneKey] + }) }) it('persists the sleeping record and suppression before issuing the hibernation kill', async () => { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 5b2137120342..374b54d245e2 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -22,6 +22,8 @@ import { createTerminalRestartActions } from '../terminals/terminal-restart-stat import { createTerminalLayoutActions } from '../terminals/terminal-layout-state' import { createTerminalStartupQueueActions } from '../terminals/terminal-startup-queues' import { createWorkspaceTerminalHydrationActions } from '../terminals/workspace-terminal-hydration' +import { createTerminalTopologyMirrorActions } from '../terminals/terminal-topology-mirror' +import { createTerminalPendingPaneActions } from '../terminals/terminal-pending-panes' import { createWorkspaceTerminalReconnectActions } from '../terminals/workspace-terminal-reconnect' export const createTerminalSlice: StateCreator = (set, get) => ({ @@ -62,6 +64,8 @@ export const createTerminalSlice: StateCreator contestedHostWorkspaceSessions: {}, contestedPrimaryHostBySessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, + terminalTopologySeqByWorktree: {}, + pendingTerminalPanes: [], closedTerminalTabTombstonesByTabId: {}, hydrationSucceeded: false, pendingReconnectWorktreeIds: [], @@ -96,5 +100,7 @@ export const createTerminalSlice: StateCreator ...createTerminalLayoutActions(set, get), ...createTerminalStartupQueueActions(set, get), ...createWorkspaceTerminalHydrationActions(set, get), + ...createTerminalTopologyMirrorActions(set), + ...createTerminalPendingPaneActions(set), ...createWorkspaceTerminalReconnectActions(set, get) }) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 4a30b52a2d79..77ba19803764 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -3,6 +3,8 @@ import type { DirectSshLayoutEdit, TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' +import type { PendingTerminalPane } from './terminal-pending-panes' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { @@ -193,7 +195,6 @@ export type TerminalActions = { clearCodexRestartNotice: (ptyId: string) => void dismissCodexRestartNotices: (ptyIds: string[]) => void reopenCodexRestartPrompt: (ptyId: string) => void - replaceTerminalLayoutPanePtyId: (tabId: string, leafId: string, ptyId: string) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void @@ -285,6 +286,13 @@ export type TerminalActions = { setDeferredSshReconnectTargets: (targetIds: string[]) => void removeDeferredSshReconnectTarget: (targetId: string) => void removeDeferredSshSessionId: (tabId: string) => void + /** Mirrors main's terminal topology per worktree; a slice older than the last applied is ignored. */ + applyTerminalTopologySlices: (slices: readonly TerminalTopologySlice[]) => void + /** Re-applies main's latest slice over a change of this window's that main refused. */ + restoreTerminalTopologySlice: (slice: TerminalTopologySlice) => void + markPendingTerminalPane: (entry: PendingTerminalPane) => void + /** Main answered `entry`'s commit; it ends once the mirror holds `publishSeq`. */ + settlePendingTerminalPane: (entry: PendingTerminalPane, publishSeq?: number) => void /** Hydrates canonical rows first, then transfers normalized pane authority post-publication. */ hydrateWorkspaceSession: ( session: WorkspaceSessionState, diff --git a/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts b/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts index 141d3141b641..741e7f614b8b 100644 --- a/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts +++ b/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts @@ -9,8 +9,8 @@ vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.f createStoreSessionMockApi() // "Is this pane running" reads the liveness map; an exit leaves the pane's layout binding in place -// for its one stale reattach. The mirror refactor keeps this split: liveness stays in the window, -// the binding becomes main's. +// for its one stale reattach (R17; the reattach-then-spawn itself is pinned in +// pty-connection-reattach-binding.test.ts). Liveness stays in the window; the binding is main's. const WORKTREE = 'repo1::/path/wt1' const LEFT = '11111111-1111-4111-8111-111111111111' @@ -73,4 +73,15 @@ describe('exited pane in a split', () => { [RIGHT]: 'pty-right' }) }) + + it("never writes main's binding, on a spawn, a handle rotation or an exit", () => { + const store = storeWithSplitTab() + + store.getState().updateTabPtyId('tab-1', 'pty-left-2', 'pty-left') + store.getState().updateTabPtyId('tab-1', 'pty-extra') + store.getState().clearTabPtyId('tab-1', 'pty-right') + store.getState().clearTabPtyId('tab-1') + + expect(store.getState().terminalLayoutsByTabId['tab-1']).toEqual(splitLayout()) + }) }) diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 455e73eb0b5f..17b7b46c705e 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -5,7 +5,7 @@ import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission import { normalizeTerminalLayoutPtyOwnership, resolveTerminalLayoutPtyOwnershipTransfers -} from '@/components/terminal-pane/terminal-layout-pty-ownership' +} from '../../../../shared/terminal-layout-pty-ownership' import { transferDirectSshPaneDetachLedger } from '../slices/direct-ssh-terminal-authority-ledger' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { @@ -21,7 +21,6 @@ export function createTerminalLayoutActions( get: TerminalStoreGet ): Pick< TerminalSlice, - | 'replaceTerminalLayoutPanePtyId' | 'setTabPaneExpanded' | 'setTabCanExpandPane' | 'setTabLayout' @@ -46,23 +45,6 @@ export function createTerminalLayoutActions( return { localOnlyScrollbackByTabId: { ...current, [tabId]: buffersByLeafId } } }) }, - replaceTerminalLayoutPanePtyId: (tabId, leafId, ptyId) => { - set((s) => { - const layout = s.terminalLayoutsByTabId[tabId] - if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { - return s - } - return { - terminalLayoutsByTabId: { - ...s.terminalLayoutsByTabId, - [tabId]: { - ...layout, - ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId } - } - } - } - }) - }, // Why: pane mount/unmount re-asserts the same booleans; bailing like setTabLayout keeps map subscribers asleep. setTabPaneExpanded: (tabId, expanded) => { set((s) => diff --git a/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts index 4d68e867770a..9ad260565651 100644 --- a/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts +++ b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts @@ -16,7 +16,6 @@ describe('terminal no-op subscriber budget', () => { for (let i = 0; i < 25; i += 1) { const s = store.getState() - s.replaceTerminalLayoutPanePtyId('missing', 'leaf', 'pty') expect(s.consumeSuppressedPtyExit('missing')).toBe(false) expect(s.consumePendingCodexPaneRestart('missing')).toBe(false) s.clearCodexRestartNotice('missing') @@ -50,7 +49,6 @@ describe('terminal no-op subscriber budget', () => { () => store.getState().markNativeChatLaunchPromptFailed('tab'), () => store.getState().markNativeChatLaunchDraftAdopted('tab'), () => store.getState().resolveNativeChatLaunchDraft('tab', draft), - () => store.getState().replaceTerminalLayoutPanePtyId('tab', 'leaf', 'pty'), () => store.getState().clearNativeChatLaunchPrompt('tab'), () => store.getState().clearNativeChatLaunchDraft('tab') ] diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts index edade70fcf4f..c8502cc30013 100644 --- a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -12,6 +12,7 @@ import { } from '../slices/agent-status' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' +import { commitSleepingRecordWrite } from './terminal-sleeping-record-commits' import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing' export function createTerminalPaneHibernationActions( @@ -81,33 +82,37 @@ export function createTerminalPaneHibernationActions( } } const rollbackTargetShutdownState = (): void => { - set((s) => { - const next = { ...s.suppressedPtyExitIds } - for (const ptyId of exitGuardPtyIds) { - delete next[ptyId] - } - const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } - for (const key of sleepingRecordKeys) { - const replaced = replacedSleepingRecords[key] - if (replaced) { - nextSleeping[key] = replaced - } else { - delete nextSleeping[key] + commitSleepingRecordWrite(get, () => + set((s) => { + const next = { ...s.suppressedPtyExitIds } + for (const ptyId of exitGuardPtyIds) { + delete next[ptyId] } - } - return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping } - }) + const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } + for (const key of sleepingRecordKeys) { + const replaced = replacedSleepingRecords[key] + if (replaced) { + nextSleeping[key] = replaced + } else { + delete nextSleeping[key] + } + } + return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping } + }) + ) } - set((s) => ({ - suppressedPtyExitIds: { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - }, - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - ...sleepingAgentSessionRecords - } - })) + commitSleepingRecordWrite(get, () => + set((s) => ({ + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + ...sleepingAgentSessionRecords + } + })) + ) if (expectedRuntimePtyIds.length > 0) { if (!runtimeEnvironmentId) { rollbackTargetShutdownState() diff --git a/src/renderer/src/store/terminals/terminal-pending-layout.test.ts b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts new file mode 100644 index 000000000000..693f4408dd7f --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { TerminalLayoutSetResult } from '../../../../shared/terminal-layout-set' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' +import { + commitPendingTerminalChange, + terminalTabPanesNamed, + withPendingTerminalPane +} from './terminal-pending-panes' + +const WT = 'repo::/wt' +const TAB = 'tab' +const A = '11111111-1111-4111-8111-111111111111' +const B = '22222222-2222-4222-8222-222222222222' +const C = '33333333-3333-4333-8333-333333333333' + +const leaf = (leafId: string): TerminalPaneLayoutNode => ({ type: 'leaf', leafId }) +const split = ( + first: TerminalPaneLayoutNode, + second: TerminalPaneLayoutNode, + ratio?: number +): TerminalPaneLayoutNode => ({ + type: 'split', + direction: 'vertical', + first, + second, + ...(ratio !== undefined ? { ratio } : {}) +}) +const BEFORE = split(leaf(A), leaf(B)) +const DRAGGED = split(leaf(A), leaf(B), 0.3) + +type Reply = TerminalLayoutSetResult & { publishSeq?: number } + +const initial = useAppStore.getState() +const originalWindow = globalThis.window +let replies: { resolve: (reply: Reply) => void; reject: (error: Error) => void }[] = [] +const setTerminalLayout = vi.fn( + (_request: unknown) => + new Promise((resolve, reject) => { + replies.push({ resolve, reject }) + }) +) + +function slice(publishSeq: number, root: TerminalPaneLayoutNode): TerminalTopologySlice { + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: [{ id: TAB, ptyId: 'pty-a', worktreeId: WT, createdAt: 1 }], + presentation: {}, + layouts: { [TAB]: { root, ptyIdsByLeafId: { [A]: 'pty-a', [B]: 'pty-b' } } }, + sleeping: {} + } +} + +const state = () => useAppStore.getState() +const rootInStore = () => state().terminalLayoutsByTabId[TAB]?.root +const apply = (publishSeq: number, root: TerminalPaneLayoutNode) => + state().applyTerminalTopologySlices([slice(publishSeq, root)]) +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +/** What the drag end does: the window takes its own tree, then commits it to main. */ +function dragEnd(root: TerminalPaneLayoutNode): void { + const layout = state().terminalLayoutsByTabId[TAB] + state().setTabLayout(TAB, { ...layout!, root }) + commitPendingTerminalChange(state(), { worktreeId: WT, tabId: TAB, change: 'layout', root }, () => + terminalTabPanesNamed(useAppStore.subscribe, useAppStore.getState, TAB).then(() => + setTerminalLayout({ worktreeId: WT, tabId: TAB, root }) + ) + ) +} + +beforeEach(() => { + replies = [] + setTerminalLayout.mockClear() + vi.stubGlobal('window', { api: { session: { setTerminalLayout } } }) + useAppStore.setState({ + tabsByWorktree: { + [WT]: [ + { + id: TAB, + ptyId: 'pty-a', + worktreeId: WT, + title: 'T', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + apply(1, BEFORE) +}) + +afterEach(() => { + vi.unstubAllGlobals() + globalThis.window = originalWindow + useAppStore.setState(initial, true) +}) + +describe('a divider drag committed to main', () => { + it('sends the tree once, and a mirror apply never sends one', async () => { + apply(2, split(leaf(A), leaf(B), 0.6)) + expect(setTerminalLayout).not.toHaveBeenCalled() + + dragEnd(DRAGGED) + await flush() + + expect(setTerminalLayout).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WT, + tabId: TAB, + root: DRAGGED + }) + }) + + it('is not snapped back by an older push, before or after the reply', async () => { + dragEnd(DRAGGED) + const kept = state().terminalLayoutsByTabId[TAB] + + apply(2, BEFORE) + expect(rootInStore()).toEqual(DRAGGED) + expect(state().terminalLayoutsByTabId[TAB]).toBe(kept) + + await flush() + replies[0]!.resolve({ status: 'committed', publishSeq: 4 }) + await flush() + apply(3, BEFORE) + expect(rootInStore()).toEqual(DRAGGED) + + apply(4, DRAGGED) + // Settled: a later host edit (another client's drag) applies. + apply(5, split(leaf(A), leaf(B), 0.8)) + expect(rootInStore()).toEqual(split(leaf(A), leaf(B), 0.8)) + }) + + it('settles on the reply when main published it first', async () => { + dragEnd(DRAGGED) + await flush() + apply(2, DRAGGED) + replies[0]!.resolve({ status: 'committed', publishSeq: 2 }) + await flush() + expect(state().pendingTerminalPanes).toEqual([]) + + apply(3, BEFORE) + expect(rootInStore()).toEqual(BEFORE) + }) + + it("yields at once to main's tree when main's tab has other panes", () => { + dragEnd(DRAGGED) + const closed = split(leaf(A), leaf(C)) + apply(2, closed) + expect(rootInStore()).toEqual(closed) + expect(state().pendingTerminalPanes).toEqual([]) + }) + + it('keeps the latest of two drags when the first reply lands', async () => { + dragEnd(DRAGGED) + const second = split(leaf(A), leaf(B), 0.2) + dragEnd(second) + await flush() + replies[0]!.resolve({ status: 'committed', publishSeq: 2 }) + await flush() + + apply(2, DRAGGED) + expect(rootInStore()).toEqual(second) + }) + + it('stops holding the tree when main could not be reached', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + dragEnd(DRAGGED) + await flush() + replies[0]!.reject(new Error('gone')) + await flush() + + apply(2, BEFORE) + expect(rootInStore()).toEqual(BEFORE) + }) + + it('waits for main to name a just-split pane, keeping the drag over pushes until then', async () => { + const splitBefore = split(BEFORE, leaf(C)) + const splitDragged = split(BEFORE, leaf(C), 0.3) + state().markPendingTerminalPane({ worktreeId: WT, tabId: TAB, leafId: C, change: 'add' }) + state().setTabLayout(TAB, { ...state().terminalLayoutsByTabId[TAB]!, root: splitBefore }) + + dragEnd(splitDragged) + await flush() + // Main hasn't recorded the split, so it would refuse the tree. + expect(setTerminalLayout).not.toHaveBeenCalled() + apply(2, BEFORE) + expect(rootInStore()).toEqual(splitDragged) + + // Main records the split from the spawn's proposed tree, which predates the drag. + apply(3, splitBefore) + expect(rootInStore()).toEqual(splitDragged) + await flush() + expect(setTerminalLayout).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WT, + tabId: TAB, + root: splitDragged + }) + + replies[0]!.resolve({ status: 'committed', publishSeq: 4 }) + await flush() + apply(4, splitDragged) + expect(state().pendingTerminalPanes).toEqual([]) + expect(rootInStore()).toEqual(splitDragged) + }) + + it("leaves the tab's pending add in place; only a later drag replaces a drag", () => { + const add = { worktreeId: WT, tabId: TAB, change: 'add' } as const + const first = { worktreeId: WT, tabId: TAB, change: 'layout', root: DRAGGED } as const + const second = { ...first, root: BEFORE } + expect(withPendingTerminalPane(withPendingTerminalPane([add], first), second)).toEqual([ + add, + second + ]) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts new file mode 100644 index 000000000000..97be25fc15c5 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import type { + TerminalTopologyReply, + TerminalTopologySlice +} from '../../../../shared/terminal-topology-slice' +import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' +import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' +import { + commitTerminalPaneIfAheadOfMain, + isPendingTerminalTab, + pendingTerminalLeafIds, + terminalTabPanesNamed +} from './terminal-pending-panes' +import { makeWorktree } from '../slices/store-test-helpers' + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +const WT = 'repo::/wt' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const initial = useAppStore.getState() +const state = () => useAppStore.getState() +const apply = (next: TerminalTopologySlice) => state().applyTerminalTopologySlices([next]) +const tabIds = () => (state().tabsByWorktree[WT] ?? []).map((tab) => tab.id) +const unifiedIds = () => (state().unifiedTabsByWorktree[WT] ?? []).map((tab) => tab.entityId) + +function slice(publishSeq: number, tabs: Record): TerminalTopologySlice { + const leaf = (leafId: string) => ({ type: 'leaf' as const, leafId }) + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: Object.keys(tabs).map((id) => ({ id, ptyId: null, worktreeId: WT, createdAt: 1 })), + presentation: {}, + layouts: Object.fromEntries( + Object.entries(tabs).map(([id, [first, second]]) => [ + id, + { + root: second + ? { + type: 'split' as const, + direction: 'vertical' as const, + first: leaf(first), + second: leaf(second) + } + : leaf(first) + } + ]) + ), + sleeping: {} + } +} + +type Reply = TerminalTopologyReply & { status?: string } + +/** Main's replies are released by the test, so either order can be driven; `held` is main's topology. */ +function stubReplies( + method: 'closeTerminalSurface' | 'createTerminalSurface', + held: TerminalTopologySlice[] = [] +): { + reply: (answer: Reply | Error) => void + calls: ReturnType +} { + const pending: ((answer: Reply | Error) => void)[] = [] + const calls = vi.fn( + () => + new Promise((resolve, reject) => { + pending.push((answer) => (answer instanceof Error ? reject(answer) : resolve(answer))) + }) + ) + const getTerminalTopologySlices = async () => held + vi.stubGlobal('window', { api: { session: { [method]: calls, getTerminalTopologySlices } } }) + return { reply: (answer) => pending.shift()?.(answer), calls } +} + +const stubCloseReplies = (held?: TerminalTopologySlice[]) => { + const { reply, calls } = stubReplies('closeTerminalSurface', held) + return { reply, closeTerminalSurface: calls } +} + +const settled = () => new Promise((resolve) => setTimeout(resolve, 0)) + +beforeEach(() => { + vi.mocked(toast.error).mockClear() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + apply(slice(1, { a: [LEAF_A] })) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + useAppStore.setState(initial, true) +}) + +describe('a tab this window creates', () => { + it('survives a push that does not yet contain it, and settles once main names it', () => { + const tab = state().createTab(WT) + + apply(slice(2, { a: [LEAF_A, LEAF_B] })) + expect(tabIds()).toEqual(['a', tab.id]) + expect(state().pendingTerminalPanes).toHaveLength(1) + + apply(slice(3, { a: [LEAF_A, LEAF_B], [tab.id]: [LEAF_B] })) + expect(tabIds()).toEqual(['a', tab.id]) + expect(state().pendingTerminalPanes).toEqual([]) + + // Main owns it now, so main dropping it removes it here. + apply(slice(4, { a: [LEAF_A] })) + expect(tabIds()).toEqual(['a']) + }) + + it('is committed to main as a new tab, and settles by the reply even if main never names it', async () => { + const main = stubReplies('createTerminalSurface') + const tab = state().createTab(WT) + expect(main.calls).toHaveBeenCalledWith({ + worktreeId: WT, + tabId: tab.id, + placement: { kind: 'new-tab', row: expect.objectContaining({ title: tab.title }) } + }) + + // Main refused it (say, closed elsewhere): the push holding the reply drops it here. + main.reply({ publishSeq: 2 }) + await settled() + apply(slice(2, { a: [LEAF_A] })) + expect(tabIds()).toEqual(['a']) + expect(state().pendingTerminalPanes).toEqual([]) + }) + + it('is dropped at once, and says so, when main refuses it', async () => { + const main = stubReplies('createTerminalSurface', [slice(1, { a: [LEAF_A] })]) + const tab = state().createTab(WT) + expect(tabIds()).toEqual(['a', tab.id]) + + main.reply({ status: 'refused', publishSeq: 1 }) + await settled() + expect(tabIds()).toEqual(['a']) + expect(state().pendingTerminalPanes).toEqual([]) + expect(toast.error).toHaveBeenCalledOnce() + }) + + it('keeps a pane main has not named yet, and only that one', () => { + state().markPendingTerminalPane({ worktreeId: WT, tabId: 'a', leafId: LEAF_B, change: 'add' }) + expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'add')).toEqual( + new Set([LEAF_B]) + ) + apply(slice(2, { a: [LEAF_A, LEAF_B] })) + expect(state().pendingTerminalPanes).toEqual([]) + }) +}) + +describe('a tab adopted for a live PTY no pane has mounted', () => { + it('is committed to main with its pane at once, not on its first attach', () => { + const main = stubReplies('createTerminalSurface') + const tab = state().createTab(WT, undefined, undefined, { + initialPtyId: 'pty-live', + activate: false, + recordInteraction: false + }) + + const [leafId] = collectLeafIds(state().terminalLayoutsByTabId[tab.id]?.root) + expect(leafId).toBeDefined() + expect(main.calls).toHaveBeenCalledWith({ + worktreeId: WT, + tabId: tab.id, + leafId, + placement: { kind: 'new-tab', row: expect.objectContaining({ title: tab.title }) } + }) + expect(isPendingTerminalTab(state().pendingTerminalPanes, WT, tab.id, 'add')).toBe(true) + }) +}) + +describe('a web client', () => { + // Its tabs reach the host through session-tab RPCs and no push from main settles them. + it('commits nothing to main and holds no pending entry for a tab it makes', () => { + const createTerminalSurface = vi.fn() + vi.stubGlobal('window', { + __ORCA_WEB_CLIENT__: true, + api: { session: { createTerminalSurface } } + }) + const tab = state().createTab(WT) + + expect(createTerminalSurface).not.toHaveBeenCalled() + expect(state().pendingTerminalPanes).toEqual([]) + expect(tabIds()).toEqual(['a', tab.id]) + }) +}) + +describe('a worktree a paired Orca server hosts', () => { + // Main publishes no slice for it, so nothing would ever settle an entry held here. + it('holds no pending entry for a tab or pane made here', () => { + useAppStore.setState({ + worktreesByRepo: { + repo: [makeWorktree({ id: WT, repoId: 'repo', hostId: 'runtime:env-1' })] + } + }) + const create = stubReplies('createTerminalSurface') + const tab = state().createTab(WT) + commitTerminalPaneIfAheadOfMain(state(), { + worktreeId: WT, + tabId: tab.id, + leafId: LEAF_B, + placement: { kind: 'new-tab' } + }) + + expect(create.calls).not.toHaveBeenCalled() + + expect(state().pendingTerminalPanes).toEqual([]) + }) +}) + +describe('a tab this window closes', () => { + it.each([ + ['the reply', true], + ['the push', false] + ])('is not brought back by an older push when %s comes first', async (_, replyFirst) => { + apply(slice(10, { a: [LEAF_A], b: [LEAF_B] })) + const main = stubCloseReplies() + state().closeTab('b') + expect(main.closeTerminalSurface).toHaveBeenCalledOnce() + + // A push main sent before it closed the tab still names it. + apply(slice(11, { a: [LEAF_A], b: [LEAF_B] })) + expect(tabIds()).toEqual(['a']) + expect(unifiedIds()).toEqual(['a']) + + if (replyFirst) { + main.reply({ publishSeq: 12 }) + await settled() + apply(slice(12, { a: [LEAF_A] })) + } else { + apply(slice(12, { a: [LEAF_A] })) + main.reply({ publishSeq: 12 }) + await settled() + } + expect(tabIds()).toEqual(['a']) + expect(state().pendingTerminalPanes).toEqual([]) + }) + + it('shows main’s copy again at once, and says so, when main refuses the close', async () => { + apply(slice(10, { a: [LEAF_A], b: [LEAF_B] })) + const main = stubCloseReplies([slice(10, { a: [LEAF_A], b: [LEAF_B] })]) + state().closeTab('b') + expect(tabIds()).toEqual(['a']) + + main.reply(new Error('write failed')) + await settled() + expect(state().pendingTerminalPanes).toEqual([]) + expect(tabIds()).toEqual(['a', 'b']) + expect(toast.error).toHaveBeenCalledOnce() + }) +}) + +describe('a pane this window closes', () => { + it('stays hidden until main’s push holding the close is applied', async () => { + const main = stubCloseReplies() + commitTerminalSurfaceClose(state(), WT, { kind: 'pane', tabId: 'a', leafId: LEAF_B }) + expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'remove')).toEqual( + new Set([LEAF_B]) + ) + main.reply({ publishSeq: 3 }) + await settled() + apply(slice(2, { a: [LEAF_A, LEAF_B] })) + expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'remove').size).toBe(1) + + apply(slice(3, { a: [LEAF_A] })) + expect(state().pendingTerminalPanes).toEqual([]) + }) +}) + +describe('a split pane this window creates', () => { + const split = { + worktreeId: WT, + tabId: 'a', + leafId: LEAF_B, + placement: { kind: 'split' as const, parentLeafId: LEAF_A, direction: 'vertical' as const } + } + + it('is committed to main before any spawn, and settles by the reply', async () => { + const main = stubReplies('createTerminalSurface') + commitTerminalPaneIfAheadOfMain(state(), split) + expect(main.calls).toHaveBeenCalledWith(split) + expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'add')).toEqual( + new Set([LEAF_B]) + ) + main.reply({ publishSeq: 2 }) + await settled() + apply(slice(2, { a: [LEAF_A, LEAF_B] })) + expect(state().pendingTerminalPanes).toEqual([]) + }) + + // A pane whose process never starts used to hold its tab's layout sends, and their listener, forever. + it('never starting does not hold its tab’s layout sends or leak a store listener', async () => { + const main = stubReplies('createTerminalSurface') + let listeners = 0 + const subscribe = (listener: () => void): (() => void) => { + listeners += 1 + const unsubscribe = useAppStore.subscribe(listener) + return () => { + listeners -= 1 + unsubscribe() + } + } + commitTerminalPaneIfAheadOfMain(state(), split) + let sent = false + void terminalTabPanesNamed(subscribe, state, 'a').then(() => (sent = true)) + await settled() + expect(sent).toBe(false) + expect(listeners).toBe(1) + + // Settled by the reply's push alone, whether or not main named the pane. + main.reply({ publishSeq: 2 }) + await settled() + apply(slice(2, { a: [LEAF_A] })) + await settled() + expect(sent).toBe(true) + expect(listeners).toBe(0) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts new file mode 100644 index 000000000000..377ad495fab2 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -0,0 +1,339 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row' +import type { TerminalSurfaceCreateRequest } from '../../../../shared/terminal-surface-create' +import type { + TerminalTopologyReply, + TerminalTopologySlice +} from '../../../../shared/terminal-topology-slice' +import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' +import { isWebClientLocation } from '@/lib/web-client-location' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { AppState } from '../types' +import type { TerminalSlice, TerminalStoreSet } from './terminal-state' + +export type PendingTerminalChangeStore = Pick< + TerminalSlice, + 'settlePendingTerminalPane' | 'restoreTerminalTopologySlice' +> + +/** A whole tab when `leafId` is absent. */ +export type PendingTerminalPaneKey = { worktreeId: string; tabId: string; leafId?: string } + +/** + * A tab or pane this window shows (`add`) or hides (`remove`), or a tab tree it keeps (`layout`, + * a user's geometry edit), before main's topology does. Each stands until the slice holding main's + * reply (`publishSeq`); an add also ends once a slice names it. A layout also ends once main's tab + * has other panes than its tree, apart from panes added here and not yet named, since main + * refuses it then. + */ +export type PendingTerminalPane = PendingTerminalPaneKey & { publishSeq?: number } & ( + | { change: 'add' | 'remove' } + | { change: 'layout'; root: TerminalPaneLayoutNode } + ) + +type PendingTerminalLayout = Extract + +function isSamePane(a: PendingTerminalPaneKey, b: PendingTerminalPaneKey): boolean { + return a.worktreeId === b.worktreeId && a.tabId === b.tabId && a.leafId === b.leafId +} + +/** A newer change to the same tab or pane replaces the older one: a close its add, a drag the + * previous drag. A tab's tree and its membership are separate changes. */ +export function withPendingTerminalPane( + pending: readonly PendingTerminalPane[], + entry: PendingTerminalPane +): PendingTerminalPane[] { + const isLayout = entry.change === 'layout' + return [ + ...pending.filter( + (current) => !isSamePane(current, entry) || (current.change === 'layout') !== isLayout + ), + entry + ] +} + +export function withoutPendingTerminalTab( + pending: PendingTerminalPane[], + tabId: string +): PendingTerminalPane[] { + return pending.some((entry) => entry.tabId === tabId) + ? pending.filter((entry) => entry.tabId !== tabId) + : pending +} + +export function isPendingTerminalTab( + pending: readonly PendingTerminalPane[], + worktreeId: string, + tabId: string, + change: PendingTerminalPane['change'] +): boolean { + return pending.some( + (entry) => + entry.worktreeId === worktreeId && + entry.tabId === tabId && + entry.leafId === undefined && + entry.change === change + ) +} + +export function pendingTerminalLeafIds( + pending: readonly PendingTerminalPane[], + tabId: string, + change: PendingTerminalPane['change'] +): Set { + return new Set( + pending.flatMap((entry) => + entry.tabId === tabId && entry.leafId && entry.change === change ? [entry.leafId] : [] + ) + ) +} + +/** The tree a pending gesture keeps over main's for this tab, if any. */ +export function pendingTerminalLayoutRoot( + pending: readonly PendingTerminalPane[], + worktreeId: string, + tabId: string +): TerminalPaneLayoutNode | undefined { + return pending.find( + (entry): entry is PendingTerminalLayout => + entry.change === 'layout' && entry.worktreeId === worktreeId && entry.tabId === tabId + )?.root +} + +const leafSet = ( + root: TerminalPaneLayoutNode | null | undefined, + except: ReadonlySet = new Set() +): string => + collectLeafIds(root) + .filter((leafId) => !except.has(leafId)) + .sort() + .join('\n') + +/** + * Main's slices carry only local and SSH worktrees. A web client's tabs and a `runtime:` host's + * come from the host's snapshot, which never settles a pending entry, so they hold none. + */ +export function isTerminalTabMirroredFromMain( + state: AppState, + worktreeId: string, + tabId: string +): boolean { + const entry = state.unifiedTabsByWorktree[worktreeId]?.find( + (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId) + ) + return ( + !isWebClientLocation() && + getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) === null && + parseExecutionHostId(entry?.executionHostId)?.kind !== 'runtime' + ) +} + +/** A creation's pending entry: the tab for a new tab, else the pane. */ +function pendingTerminalSurfaceCreate({ + worktreeId, + tabId, + leafId, + placement +}: TerminalSurfaceCreateRequest): PendingTerminalPane { + return { + worktreeId, + tabId, + ...(placement.kind !== 'new-tab' && leafId ? { leafId } : {}), + change: 'add' + } +} + +/** A new tab's creation commit, and the entry that shows it here until main's reply. */ +export function newTerminalTabCreate( + tab: TerminalTab, + leafId: string | undefined +): { request: TerminalSurfaceCreateRequest; entry: PendingTerminalPane } { + const request: TerminalSurfaceCreateRequest = { + worktreeId: tab.worktreeId, + tabId: tab.id, + ...(leafId ? { leafId } : {}), + placement: { kind: 'new-tab', row: terminalPanePlacementRow(tab) } + } + return { request, entry: pendingTerminalSurfaceCreate(request) } +} + +/** Commits a creation this window shows as `entry` into main, unbound; main's reply settles it. */ +export function sendTerminalSurfaceCreate( + store: PendingTerminalChangeStore, + entry: PendingTerminalPane, + request: TerminalSurfaceCreateRequest +): void { + settlePendingTerminalChangeOnReply(store, entry, () => + // Why optional: an older preload can linger through an in-place renderer reload. + globalThis.window?.api?.session?.createTerminalSurface?.(request) + ) +} + +/** A pane this window made, which main's layout doesn't name yet, is created there. */ +export function commitTerminalPaneIfAheadOfMain( + state: AppState, + pane: TerminalSurfaceCreateRequest & { leafId: string } +): void { + if ( + !collectLeafIds(state.terminalLayoutsByTabId[pane.tabId]?.root).includes(pane.leafId) && + isTerminalTabMirroredFromMain(state, pane.worktreeId, pane.tabId) + ) { + const entry = pendingTerminalSurfaceCreate(pane) + state.markPendingTerminalPane(entry) + sendTerminalSurfaceCreate(state, entry, pane) + } +} + +/** The entries still pending once `slice` is applied; the same array when it settles none. */ +export function pendingAfterTerminalTopologySlice( + pending: PendingTerminalPane[], + slice: TerminalTopologySlice +): PendingTerminalPane[] { + const tabIds = new Set(slice.tabs.map((tab) => tab.id)) + const leafIds = new Set(Object.values(slice.layouts).flatMap(({ root }) => collectLeafIds(root))) + const named = (entry: PendingTerminalPane): boolean => + entry.leafId ? leafIds.has(entry.leafId) : tabIds.has(entry.tabId) + const settled = (entry: PendingTerminalPane): boolean => { + if (entry.worktreeId !== slice.worktreeId) { + return false + } + if (entry.change === 'add' && named(entry)) { + return true + } + // Panes added here that main hasn't named yet don't make a gesture's tree stale. + const unnamed = new Set( + pending.flatMap((other) => + other.change === 'add' && other.tabId === entry.tabId && other.leafId && !named(other) + ? [other.leafId] + : [] + ) + ) + if ( + entry.change === 'layout' && + leafSet(slice.layouts[entry.tabId]?.root) !== leafSet(entry.root, unnamed) + ) { + return true + } + return entry.publishSeq !== undefined && entry.publishSeq <= slice.publishSeq + } + return pending.some(settled) ? pending.filter((entry) => !settled(entry)) : pending +} + +/** Resolves once `ready` holds, re-checked after every store change. */ +export function terminalStoreReady( + subscribe: (listener: () => void) => () => void, + ready: () => boolean +): Promise { + return new Promise((resolve) => { + if (ready()) { + resolve() + return + } + const unsubscribe = subscribe(() => { + if (ready()) { + unsubscribe() + resolve() + } + }) + }) +} + +/** Main refuses a tree naming a pane it hasn't recorded, so a gesture right after a split waits. */ +export function terminalTabPanesNamed( + subscribe: (listener: () => void) => () => void, + getState: () => Pick, + tabId: string +): Promise { + return terminalStoreReady( + subscribe, + () => pendingTerminalLeafIds(getState().pendingTerminalPanes, tabId, 'add').size === 0 + ) +} + +/** + * Holds `entry` over main's pushes while `send` commits it in main, until the push holding main's + * reply is applied. A later change to the same target replaces `entry`; this reply then settles nothing. + */ +export function commitPendingTerminalChange( + store: PendingTerminalChangeStore & Pick, + entry: PendingTerminalPane, + send: () => Promise | undefined +): void { + store.markPendingTerminalPane(entry) + settlePendingTerminalChangeOnReply(store, entry, send) +} + +/** Settles `entry`, which this window already shows, once `send`'s reply names main's push. */ +function settlePendingTerminalChangeOnReply( + store: PendingTerminalChangeStore, + entry: PendingTerminalPane, + send: () => Promise<(TerminalTopologyReply & { status?: string }) | undefined> | undefined +): void { + void Promise.resolve(send()).then( + (reply) => + reply?.status === 'refused' + ? restoreMainTerminalTopology(store, entry, reply) + : store.settlePendingTerminalPane(entry, reply?.publishSeq), + (error: unknown) => restoreMainTerminalTopology(store, entry, error) + ) +} + +/** + * Main refused `entry` or rolled its write back, so no push will ever undo what this window shows; + * main's slice for the worktree replaces it now. + */ +async function restoreMainTerminalTopology( + store: PendingTerminalChangeStore, + entry: PendingTerminalPane, + cause: unknown +): Promise { + console.warn(`[terminal-topology] main did not commit the ${entry.change}`, cause) + store.settlePendingTerminalPane(entry) + toast.error( + translate( + 'terminal.topologyChange.refused', + "Couldn't save that terminal change, so it was undone." + ) + ) + try { + const slices = await globalThis.window?.api?.session?.getTerminalTopologySlices?.() + const slice = slices?.find((candidate) => candidate.worktreeId === entry.worktreeId) + if (slice) { + store.restoreTerminalTopologySlice(slice) + } + } catch (error) { + // The next push for the worktree still restores main's topology. + console.warn("[terminal-topology] could not pull main's topology", error) + } +} + +export function createTerminalPendingPaneActions( + set: TerminalStoreSet +): Pick { + return { + markPendingTerminalPane: (entry) => { + set((s) => ({ pendingTerminalPanes: withPendingTerminalPane(s.pendingTerminalPanes, entry) })) + }, + settlePendingTerminalPane: (entry, publishSeq) => { + set((s) => { + if (!s.pendingTerminalPanes.includes(entry)) { + return s + } + // No publishSeq (refused, or no mirror) or one already applied: main's state stands. + const stands = + publishSeq === undefined || + publishSeq <= (s.terminalTopologySeqByWorktree[entry.worktreeId] ?? 0) + return { + pendingTerminalPanes: stands + ? s.pendingTerminalPanes.filter((candidate) => candidate !== entry) + : s.pendingTerminalPanes.map((candidate) => + candidate === entry ? { ...candidate, publishSeq } : candidate + ) + } + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index 499ea63e8ded..10dcaff250b9 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -1,4 +1,5 @@ import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { isTerminalTabPresent } from '../slices/terminal-tab-retirement' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' @@ -12,6 +13,23 @@ import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { omitDisownedPtyIds } from './terminal-disowned-pty-sources' +/** A remote runtime's pane bindings are this window's, pushed to its host; they follow a handle rotation. */ +function rotateRemotePaneBinding( + layout: TerminalLayoutSnapshot | undefined, + replacedPtyId: string, + ptyId: string +): TerminalLayoutSnapshot | null { + const bindings = layout?.ptyIdsByLeafId + if (!layout || !bindings || !Object.values(bindings).includes(replacedPtyId)) { + return null + } + const rotated = Object.entries(bindings).map(([leafId, id]) => [ + leafId, + id === replacedPtyId ? ptyId : id + ]) + return { ...layout, ptyIdsByLeafId: Object.fromEntries(rotated) } +} + export function createTerminalPtyBindingActions( set: TerminalStoreSet, get: TerminalStoreGet @@ -64,30 +82,11 @@ export function createTerminalPtyBindingActions( : existingPtyIds.includes(ptyId) ? existingPtyIds : [...existingPtyIds, ptyId] - // Keep provider handle rotation atomic across tab and pane ownership. - let nextTerminalLayoutsByTabId = s.terminalLayoutsByTabId - if (replacementPtyId) { - const existingLayout = s.terminalLayoutsByTabId[tabId] - const existingBindings = existingLayout?.ptyIdsByLeafId - if (existingLayout && existingBindings) { - let changed = false - const nextBindings = Object.fromEntries( - Object.entries(existingBindings).map(([leafId, currentPtyId]) => { - if (currentPtyId !== replacementPtyId) { - return [leafId, currentPtyId] - } - changed = true - return [leafId, ptyId] - }) - ) - if (changed) { - nextTerminalLayoutsByTabId = { - ...s.terminalLayoutsByTabId, - [tabId]: { ...existingLayout, ptyIdsByLeafId: nextBindings } - } - } - } - } + // Liveness only: main owns a local or SSH pane's binding and mirrors it into the layout. + const rotatedLayout = + replacementPtyId && isRemoteRuntimePtyId(replacementPtyId) + ? rotateRemotePaneBinding(s.terminalLayoutsByTabId[tabId], replacementPtyId, ptyId) + : null let nextTabsByWorktree = s.tabsByWorktree for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { const index = tabs.findIndex((t) => t.id === tabId) @@ -270,8 +269,8 @@ export function createTerminalPtyBindingActions( migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, - ...(nextTerminalLayoutsByTabId !== s.terminalLayoutsByTabId - ? { terminalLayoutsByTabId: nextTerminalLayoutsByTabId } + ...(rotatedLayout + ? { terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: rotatedLayout } } : {}), ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } diff --git a/src/renderer/src/store/terminals/terminal-shutdown-state.ts b/src/renderer/src/store/terminals/terminal-shutdown-state.ts index 389c8166a18e..a608cd2207f2 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown-state.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown-state.ts @@ -13,6 +13,7 @@ import { } from '../slices/agent-status' import type { TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { copyOnWriteRecord } from '../copy-on-write-record' +import { commitSleepingRecordWrite } from './terminal-sleeping-record-commits' export function commitTerminalShutdownState({ exitGuardPtyIds, @@ -146,23 +147,25 @@ export function commitTerminalShutdownState({ }) if (keepIdentifiers) { - set((state) => { - const base = - shutdownReason === 'manual-sleep' - ? removeSleepingRecordsReplacedByManualWorktreeSleep( - state.sleepingAgentSessionsByPaneKey, - worktreeId, - sleepingPaneKeys, - sleepingAgentSessionRecords - ).records - : state.sleepingAgentSessionsByPaneKey - return { - sleepingAgentSessionsByPaneKey: { - ...base, - ...sleepingAgentSessionRecords + commitSleepingRecordWrite(get, () => + set((state) => { + const base = + shutdownReason === 'manual-sleep' + ? removeSleepingRecordsReplacedByManualWorktreeSleep( + state.sleepingAgentSessionsByPaneKey, + worktreeId, + sleepingPaneKeys, + sleepingAgentSessionRecords + ).records + : state.sleepingAgentSessionsByPaneKey + return { + sleepingAgentSessionsByPaneKey: { + ...base, + ...sleepingAgentSessionRecords + } } - } - }) + }) + ) } else { get().clearSleepingAgentSessionsByWorktree(worktreeId) } diff --git a/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts new file mode 100644 index 000000000000..d4b6129744e2 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore, makeTab } from '../slices/store-test-helpers' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = `tab-1:${LEAF}` +const MOVED = `tab-2:${LEAF}` +const session = { key: 'session_id' as const, id: 'codex-session-1' } + +let api: { + commitTerminalSleepingRecords: ReturnType + closeTerminalSurface: ReturnType +} + +beforeEach(() => { + api = { + commitTerminalSleepingRecords: vi.fn(async () => {}), + closeTerminalSurface: vi.fn(async () => ({})) + } + vi.stubGlobal('window', { api: { session: api } }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +/** A store whose agent pane already has a committed sleeping record. */ +function storeWithSleepingAgent() { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [ + makeTab({ id: 'tab-1', worktreeId: 'wt-1' }), + makeTab({ id: 'tab-2', worktreeId: 'wt-1' }) + ] + } + }) + store + .getState() + .setAgentStatus( + PANE, + { state: 'working', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: session } + ) + api.commitTerminalSleepingRecords.mockClear() + return store +} + +describe("this window's sleeping-record commits", () => { + it('a changed record sleeps its leaf, sent with the write', () => { + const store = storeWithSleepingAgent() + expect(store.getState().sleepingAgentSessionsByPaneKey[PANE]?.providerSession).toEqual(session) + + store + .getState() + .recordAgentProviderSession( + PANE, + 'codex', + { ...session, id: 'codex-session-2' }, + { updatedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' } + ) + + expect(api.commitTerminalSleepingRecords).toHaveBeenCalledTimes(1) + expect(api.commitTerminalSleepingRecords).toHaveBeenCalledWith({ + sleep: { [PANE]: store.getState().sleepingAgentSessionsByPaneKey[PANE] }, + wake: [] + }) + }) + + it('the periodic capture commits what it captured', () => { + const store = storeWithSleepingAgent() + // A live agent whose record main's last push dropped. + store.setState({ sleepingAgentSessionsByPaneKey: {} }) + + store.getState().captureAllSleepingAgentSessions('periodic') + const captured = store.getState().sleepingAgentSessionsByPaneKey[PANE] + + expect(captured?.providerSession).toEqual(session) + expect(api.commitTerminalSleepingRecords).toHaveBeenCalledWith({ + sleep: { [PANE]: captured }, + wake: [] + }) + }) + + it('the quit capture is sent before the capture returns, so it precedes the quit stage', () => { + const store = storeWithSleepingAgent() + + store.getState().captureAllSleepingAgentSessions('quit') + + const sent = api.commitTerminalSleepingRecords.mock.calls.at(-1)?.[0] + expect(sent?.sleep[PANE]).toBe(store.getState().sleepingAgentSessionsByPaneKey[PANE]) + expect(sent?.sleep[PANE]?.origin).toBe('quit') + }) + + it('a removal wakes its leaf', () => { + const store = storeWithSleepingAgent() + + store.getState().clearSleepingAgentSession(PANE) + + expect(api.commitTerminalSleepingRecords).toHaveBeenCalledWith({ sleep: {}, wake: [PANE] }) + }) + + it('a pane move and a tab close commit nothing: main moves and closes them itself', () => { + const store = storeWithSleepingAgent() + + store.getState().transferAgentPaneAuthority({ fromPaneKey: PANE, toPaneKey: MOVED }) + expect(Object.keys(store.getState().sleepingAgentSessionsByPaneKey)).toEqual([MOVED]) + store.getState().closeTab('tab-2') + expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({}) + + expect(api.commitTerminalSleepingRecords).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts new file mode 100644 index 000000000000..5485424480db --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts @@ -0,0 +1,36 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { TerminalSleepingRecordChanges } from '../../../../shared/terminal-topology-slice' + +type SleepingRecords = Record + +/** + * Runs `write`, then sends main the window's own change to its sleeping-agent records. Sent at + * once: a window's IPC reaches main in order, so the quit capture lands before the quit stage. + * Not for a mirror apply, a tab close or a pane move: main already holds those. + */ +export function commitSleepingRecordWrite( + get: () => { sleepingAgentSessionsByPaneKey: SleepingRecords }, + write: () => void +): void { + const before = get().sleepingAgentSessionsByPaneKey + write() + const after = get().sleepingAgentSessionsByPaneKey + if (before === after) { + return + } + const changes: TerminalSleepingRecordChanges = { + sleep: Object.fromEntries( + Object.entries(after).filter(([paneKey, record]) => before[paneKey] !== record) + ), + wake: Object.keys(before).filter((paneKey) => !Object.hasOwn(after, paneKey)) + } + if (Object.keys(changes.sleep).length === 0 && changes.wake.length === 0) { + return + } + // Why optional: an older preload can linger through an in-place renderer reload. + void Promise.resolve( + globalThis.window?.api?.session?.commitTerminalSleepingRecords?.(changes) + ).catch((error: unknown) => + console.warn('[terminal-sleep] main did not commit sleeping records', error) + ) +} diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index d0595b8721f8..9fc77f8eadc5 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -22,6 +22,7 @@ import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-c import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TerminalActions } from './terminal-actions' +import type { PendingTerminalPane } from './terminal-pending-panes' export type DirectSshLayoutEdit = { targetId: string @@ -113,6 +114,10 @@ export type TerminalState = { /** Partition each restored session key was read from, so a write returns its rows there. */ contestedPrimaryHostBySessionKey: Record defaultTerminalTabsAppliedByWorktreeId: Record + /** The publishSeq of main's last applied terminal topology slice per worktree. */ + terminalTopologySeqByWorktree: Record + /** What this window shows or hides ahead of main's topology; see terminal-pending-panes.ts. */ + pendingTerminalPanes: PendingTerminalPane[] closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId hydrationSucceeded: boolean pendingReconnectWorktreeIds: string[] diff --git a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts index 25393aa8129b..8e0d18d7ca28 100644 --- a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts +++ b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts @@ -1,16 +1,32 @@ import type { TerminalSurfaceCloseTarget } from '../../../../shared/terminal-surface-close-target' +import { + commitPendingTerminalChange, + type PendingTerminalChangeStore +} from './terminal-pending-panes' +import type { TerminalSlice } from './terminal-state' -/** Commits a terminal tab or split-pane close in main, which owns membership: a renderer save - * cannot shrink it, so without this the close would ride on the PTY exit. */ +/** Commits a terminal tab or split-pane close in main, which owns membership. The surface stays + * hidden here until main's topology drops it, so an older push cannot bring it back. */ export function commitTerminalSurfaceClose( + store: PendingTerminalChangeStore & Pick, worktreeId: string, target: TerminalSurfaceCloseTarget, reason?: 'user' | 'cleanup' ): void { - // Why optional: an older preload can linger through an in-place renderer reload. - void globalThis.window?.api?.session - ?.closeTerminalSurface?.({ worktreeId, target, ...(reason ? { reason } : {}) }) - ?.catch((error: unknown) => { - console.warn('[terminal-close] main did not commit the close', error) - }) + commitPendingTerminalChange( + store, + { + worktreeId, + tabId: target.tabId, + ...(target.kind === 'pane' ? { leafId: target.leafId } : {}), + change: 'remove' + }, + // Why optional: an older preload can linger through an in-place renderer reload. + () => + globalThis.window?.api?.session?.closeTerminalSurface?.({ + worktreeId, + target, + ...(reason ? { reason } : {}) + }) + ) } diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 9361391a9e67..157177742ea6 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -14,6 +14,7 @@ import { import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { startTerminalTabProviderRetirement } from './terminal-tab-close-providers' import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' +import { withoutPendingTerminalTab } from './terminal-pending-panes' import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { removePaneKeysByTabPrefix } from '../slices/agent-status-pane-keyed-records' import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' @@ -196,6 +197,7 @@ export function createTerminalTabCloseActions( } return { tabsByWorktree: next, + pendingTerminalPanes: withoutPendingTerminalTab(s.pendingTerminalPanes, tabId), activeTabId: s.activeTabId === tabId ? null : s.activeTabId, activeTabIdByWorktree: nextActiveTabIdByWorktree, ptyIdsByTabId: nextPtyIdsByTabId, @@ -259,7 +261,7 @@ export function createTerminalTabCloseActions( } }) if (intentReason && closingWorktreeId && opts?.remoteCloseOwnedByHost !== true) { - commitTerminalSurfaceClose(closingWorktreeId, { kind: 'tab', tabId }, intentReason) + commitTerminalSurfaceClose(get(), closingWorktreeId, { kind: 'tab', tabId }, intentReason) } // Why only a user close: it is the explicit abandon. Other closes keep the drafts until their // workspace is removed in Orca; drafts have no budget that retires them. diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 79ee1e522332..71fce9ab3e25 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -20,6 +20,12 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { ownsGlobalSelection } from '../global-selection-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + isTerminalTabMirroredFromMain, + newTerminalTabCreate, + sendTerminalSurfaceCreate, + withPendingTerminalPane +} from './terminal-pending-panes' import { getRemoteConnectionIdForWorktree, resolveCreatedTabShellOverride, @@ -61,6 +67,7 @@ export function createTerminalTabCreationActions( return { createTab: (worktreeId, targetGroupId, shellOverride, options) => { let tab!: TerminalTab + let created: ReturnType | undefined set((s) => { const orphanTerminalIds = getOrphanTerminalIds(s, worktreeId) const orphanCleanupPatch = buildOrphanTerminalCleanupPatch(s, worktreeId, orphanTerminalIds) @@ -137,6 +144,10 @@ export function createTerminalTabCreationActions( // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) } + // Main records the tab now, so it outlives a spawn that fails or never starts. + if (isTerminalTabMirroredFromMain(s, worktreeId, id)) { + created = newTerminalTabCreate(tab, initialLeafId) + } if (options?.launchAgent === 'zcode') { // Why here: this is where a ZCode launch is first known, and it runs before the // pane connects, so the explanation can beat the stack trace to the screen. @@ -229,6 +240,10 @@ export function createTerminalTabCreationActions( ...orphanCleanupPatch.tabsByWorktree, [worktreeId]: [...existing, tab] }, + // Shown before main's push names it; the reply to `created` settles it. + ...(created && { + pendingTerminalPanes: withPendingTerminalPane(s.pendingTerminalPanes, created.entry) + }), // Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host. unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, @@ -284,6 +299,9 @@ export function createTerminalTabCreationActions( } } }) + if (created) { + sendTerminalSurfaceCreate(get(), created.entry, created.request) + } if (options?.initialPtyId) { // Why: a tab born with a live PTY (CLI/runtime create) wakes the workspace like any other bind. clearWorktreeSleepIntent(worktreeId) diff --git a/src/renderer/src/store/terminals/terminal-tab-presentation.ts b/src/renderer/src/store/terminals/terminal-tab-presentation.ts index 7e1d2f644428..3a5cc5f4190d 100644 --- a/src/renderer/src/store/terminals/terminal-tab-presentation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-presentation.ts @@ -139,6 +139,10 @@ export function createTerminalTabPresentationActions( const nextTabs = [...tabs] nextTabs[tabIndex] = tabWithoutLaunchAgent scheduleRuntimeGraphSync() + // Main holds the row; without this its push and the next save bring the agent back. + void globalThis.window?.api?.session + ?.clearTerminalLaunchAgent?.({ worktreeId: ownerWorktreeId, tabId }) + ?.catch((error: unknown) => console.warn('[terminal] launch agent not cleared', error)) return { tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: nextTabs } } }) }, diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts b/src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts new file mode 100644 index 000000000000..b065e8643f9f --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts @@ -0,0 +1,119 @@ +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { AppState } from '../types' +import { + dedupeTabOrder, + ensureGroup, + pickNextActiveTab, + sanitizeRecentTabIds +} from '../slices/tab-group-state' + +type UnifiedTabsPatch = Partial< + Pick< + AppState, + 'unifiedTabsByWorktree' | 'groupsByWorktree' | 'activeGroupIdByWorktree' | 'layoutByWorktree' + > +> + +function isTerminalEntryFor(tab: Tab, terminalIds: ReadonlySet): boolean { + return ( + tab.contentType === 'terminal' && (terminalIds.has(tab.entityId) || terminalIds.has(tab.id)) + ) +} + +function withoutTabs(group: TabGroup, removed: ReadonlySet): TabGroup { + const tabOrder = group.tabOrder.filter((id) => !removed.has(id)) + if (tabOrder.length === group.tabOrder.length) { + return group + } + const activeTabId = + group.activeTabId && removed.has(group.activeTabId) + ? pickNextActiveTab(tabOrder, group.recentTabIds, group.activeTabId) + : group.activeTabId + return { + ...group, + tabOrder, + activeTabId, + recentTabIds: sanitizeRecentTabIds(group.recentTabIds, tabOrder) + } +} + +function unifiedTerminalTab(tab: TerminalTab, groupId: string, sortOrder: number): Tab { + return { + id: tab.id, + entityId: tab.id, + groupId, + worktreeId: tab.worktreeId, + contentType: 'terminal', + label: tab.title, + ...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder, + createdAt: tab.createdAt + } +} + +/** Joins terminal rows to unified tabs on `entityId` or `id`; a new row joins the active group. */ +export function mirrorTerminalUnifiedTabs( + state: AppState, + worktreeId: string, + added: readonly TerminalTab[], + removedIds: readonly string[] +): UnifiedTabsPatch { + const current = state.unifiedTabsByWorktree[worktreeId] ?? [] + const removedSet = new Set(removedIds) + const represented = new Set( + current.flatMap((tab) => (tab.contentType === 'terminal' ? [tab.id, tab.entityId] : [])) + ) + const joining = added.filter((tab) => !represented.has(tab.id)) + const removedEntryIds = new Set( + current.filter((tab) => isTerminalEntryFor(tab, removedSet)).map((tab) => tab.id) + ) + if (joining.length === 0 && removedEntryIds.size === 0) { + return {} + } + const ensured = + joining.length > 0 + ? ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + state.activeGroupIdByWorktree[worktreeId] + ) + : null + const groups = (ensured?.groupsByWorktree ?? state.groupsByWorktree)[worktreeId] ?? [] + const target = ensured?.group + const joined = target + ? joining.map((tab, index) => + unifiedTerminalTab(tab, target.id, target.tabOrder.length + index) + ) + : [] + const nextGroups = groups.map((group) => { + const pruned = withoutTabs(group, removedEntryIds) + if (group.id !== target?.id) { + return pruned + } + const tabOrder = dedupeTabOrder([...pruned.tabOrder, ...joined.map((tab) => tab.id)]) + return { ...pruned, tabOrder, activeTabId: pruned.activeTabId ?? tabOrder[0] ?? null } + }) + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: [...current.filter((tab) => !removedEntryIds.has(tab.id)), ...joined] + }, + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, + ...(ensured && ensured.activeGroupIdByWorktree !== state.activeGroupIdByWorktree + ? { + activeGroupIdByWorktree: ensured.activeGroupIdByWorktree, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: state.layoutByWorktree[worktreeId] ?? { + type: 'leaf', + groupId: ensured.group.id + } + } + } + : {}) + } +} diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts new file mode 100644 index 000000000000..bbe253d3915c --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts @@ -0,0 +1,526 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { createSessionWriteSubscriber } from '@/lib/session-write-subscriber' +import { clearDirectSshTerminalBindings } from '../slices/direct-ssh-terminal-recovery' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + TerminalTopologySlice, + TerminalTopologyTabRow +} from '../../../../shared/terminal-topology-slice' + +const WT = 'repo::/wt' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const LEAF_C = '33333333-3333-4333-8333-333333333333' + +const initial = useAppStore.getState() + +function windowTab(id: string, overrides: Partial = {}): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId: WT, + title: `Window ${id}`, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function unified(id: string, overrides: Partial = {}): Tab { + return { + id, + entityId: id, + groupId: 'group', + worktreeId: WT, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function leafLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId }, + buffersByLeafId: { [leafId]: 'scrollback' } + } +} + +function row(id: string, overrides: Partial = {}): TerminalTopologyTabRow { + return { id, ptyId: `pty-${id}`, worktreeId: WT, createdAt: 1, ...overrides } +} + +function slice(publishSeq: number, overrides: Partial = {}) { + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: [row('a'), row('b')], + presentation: {}, + layouts: { + a: { root: { type: 'leaf', leafId: LEAF_A }, ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } }, + b: { root: { type: 'leaf', leafId: LEAF_B }, ptyIdsByLeafId: { [LEAF_B]: 'pty-b' } } + }, + sleeping: {}, + ...overrides + } satisfies TerminalTopologySlice +} + +const group: TabGroup = { id: 'group', worktreeId: WT, activeTabId: 'a', tabOrder: ['a', 'b'] } + +function seed(): void { + useAppStore.setState({ + tabsByWorktree: { + [WT]: [ + windowTab('a', { + customTitle: 'Mine', + color: '#f00', + isPinned: true, + generation: 3, + pendingActivationSpawn: 2, + agentLaunchPane: { leafId: LEAF_A, operationId: 'op' } + }), + windowTab('b', { sortOrder: 1 }) + ] + }, + terminalLayoutsByTabId: { a: leafLayout(LEAF_A, 'pty-a'), b: leafLayout(LEAF_B, 'pty-b') }, + ptyIdsByTabId: { a: ['pty-a'], b: ['pty-b'] }, + unifiedTabsByWorktree: { + [WT]: [ + unified('a'), + unified('b', { sortOrder: 1 }), + unified('/wt/readme.md', { contentType: 'editor' }) + ] + }, + groupsByWorktree: { [WT]: [{ ...group, tabOrder: ['a', 'b', '/wt/readme.md'] }] }, + activeGroupIdByWorktree: { [WT]: 'group' } + }) + useAppStore.getState().applyTerminalTopologySlices([slice(1)]) +} + +const apply = (next: TerminalTopologySlice) => + useAppStore.getState().applyTerminalTopologySlices([next]) +const state = () => useAppStore.getState() + +afterEach(() => { + vi.useRealTimers() + useAppStore.setState(initial, true) +}) + +describe('applyTerminalTopologySlices', () => { + beforeEach(seed) + + it('keeps presentation, liveness and activation state while replacing topology', () => { + const ptyIdsByTabId = state().ptyIdsByTabId + apply( + slice(2, { + tabs: [row('a', { ptyId: 'pty-main', launchAgent: 'codex' }), row('b')], + layouts: { + ...slice(2).layouts, + a: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_C } + }, + ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_C]: 'pty-c' }, + titlesByLeafId: { [LEAF_C]: 'logs' } + } + } + }) + ) + + const tab = state().tabsByWorktree[WT][0] + expect(tab).toMatchObject({ + launchAgent: 'codex', + customTitle: 'Mine', + color: '#f00', + isPinned: true, + generation: 3, + pendingActivationSpawn: 2, + agentLaunchPane: { leafId: LEAF_A, operationId: 'op' }, + // The row's ptyId is the window's live attachment, not main's last binding. + ptyId: 'pty-a' + }) + expect(state().terminalLayoutsByTabId.a).toEqual({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_C } + }, + activeLeafId: LEAF_A, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_C]: 'pty-c' }, + // Pane titles are the window's presentation, like its buffers. + buffersByLeafId: { [LEAF_A]: 'scrollback' } + }) + expect(state().ptyIdsByTabId).toBe(ptyIdsByTabId) + }) + + it("keeps a pane title the window set before main's push lands, and saves it", () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + + state().setTabLayout('a', { + ...leafLayout(LEAF_A, 'pty-a'), + titlesByLeafId: { [LEAF_A]: 'build' } + }) + // A push inside the save debounce, carrying the title main last saved. + apply( + slice(2, { + layouts: { + ...slice(2).layouts, + a: { ...slice(2).layouts.a, titlesByLeafId: { [LEAF_A]: 'old' } } + } + }) + ) + vi.advanceTimersByTime(1_000) + + expect(state().terminalLayoutsByTabId.a.titlesByLeafId).toEqual({ [LEAF_A]: 'build' }) + expect(persist).toHaveBeenCalledTimes(1) + expect(persist.mock.calls[0][0].patch.terminalLayoutsByTabId.a.titlesByLeafId).toEqual({ + [LEAF_A]: 'build' + }) + dispose() + }) + + it('never puts back an SSH attachment the window cleared for a lost connection', () => { + const cleared = clearDirectSshTerminalBindings(state(), new Set([WT])).patch + useAppStore.setState(cleared ?? {}) + // Main keeps the persisted binding; reattaching it is main's verdict, not the push's. + apply(slice(2, { tabs: [row('a', { ptyId: 'pty-a' }), row('b')] })) + + expect(state().tabsByWorktree[WT].map((tab) => tab.ptyId)).toEqual([null, null]) + expect(state().ptyIdsByTabId).toEqual({ a: [], b: [] }) + }) + + it('clears an optional row field main dropped', () => { + apply(slice(2, { tabs: [row('a', { launchAgent: 'codex' }), row('b')] })) + apply(slice(3)) + expect(state().tabsByWorktree[WT][0]).not.toHaveProperty('launchAgent') + }) + + it('leaves the store untouched by an identical slice and schedules no save', () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + const before = state() + + apply(slice(2)) + + for (const key of [ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'sleepingAgentSessionsByPaneKey', + 'ptyIdsByTabId' + ] as const) { + expect(state()[key]).toBe(before[key]) + } + vi.advanceTimersByTime(1_000) + expect(persist).not.toHaveBeenCalled() + dispose() + }) + + it('never echoes a changed slice back as a session save', () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + + apply( + slice(2, { + tabs: [row('a', { launchAgent: 'codex' }), row('b')], + layouts: { + ...slice(2).layouts, + b: { root: { type: 'leaf', leafId: LEAF_B }, ptyIdsByLeafId: { [LEAF_B]: 'pty-b2' } } + } + }) + ) + vi.advanceTimersByTime(1_000) + expect(persist).not.toHaveBeenCalled() + + // A later local edit still saves. + state().setTabCustomTitle('a', 'renamed') + vi.advanceTimersByTime(1_000) + expect(persist).toHaveBeenCalledTimes(1) + dispose() + }) + + // A hard kill must not rebuild a tab main created somewhere else in the tab bar. + it('saves the tab-bar entry it gives a tab main created, and nothing main holds', () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + + apply(slice(2, { tabs: [...slice(2).tabs, row('c')] })) + vi.advanceTimersByTime(1_000) + + expect(persist).toHaveBeenCalledTimes(1) + const { patch } = persist.mock.calls[0][0] + expect(patch.unifiedTabs[WT].map((tab: Tab) => tab.id)).toEqual([ + 'a', + 'b', + '/wt/readme.md', + 'c' + ]) + expect(patch.tabGroups[WT][0].tabOrder).toEqual(['a', 'b', '/wt/readme.md', 'c']) + expect(patch).not.toHaveProperty('tabsByWorktree') + expect(patch).not.toHaveProperty('terminalLayoutsByTabId') + dispose() + }) + + it('keeps every other tab and layout reference when one tab changes', () => { + const before = state() + apply( + slice(2, { + layouts: { + ...slice(2).layouts, + b: { root: { type: 'leaf', leafId: LEAF_B }, ptyIdsByLeafId: { [LEAF_B]: 'pty-b2' } } + } + }) + ) + expect(state().terminalLayoutsByTabId.a).toBe(before.terminalLayoutsByTabId.a) + expect(state().terminalLayoutsByTabId.b.ptyIdsByLeafId).toEqual({ [LEAF_B]: 'pty-b2' }) + expect(state().tabsByWorktree).toBe(before.tabsByWorktree) + }) + + it('removes a tab and its unified entry joined on entityId, leaving other tabs alone', () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + [WT]: [ + unified('a'), + unified('unified-b', { entityId: 'b' }), + unified('/wt/readme.md', { contentType: 'editor' }) + ] + }, + groupsByWorktree: { + [WT]: [ + { + ...group, + activeTabId: 'unified-b', + tabOrder: ['a', 'unified-b', '/wt/readme.md'], + recentTabIds: ['a', 'unified-b'] + } + ] + } + }) + + apply(slice(2, { tabs: [row('a')], layouts: { a: slice(2).layouts.a } })) + + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a']) + expect(state().terminalLayoutsByTabId).not.toHaveProperty('b') + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', '/wt/readme.md']) + expect(state().groupsByWorktree[WT][0]).toMatchObject({ + tabOrder: ['a', '/wt/readme.md'], + activeTabId: 'a', + recentTabIds: ['a'] + }) + }) + + it('appends a new tab to the active group without activating it', () => { + apply( + slice(2, { + tabs: [...slice(2).tabs, row('c', { defaultTitle: 'Terminal 3', launchAgent: 'claude' })], + layouts: { + ...slice(2).layouts, + c: { + root: { type: 'leaf', leafId: LEAF_C }, + ptyIdsByLeafId: { [LEAF_C]: 'pty-c' }, + titlesByLeafId: { [LEAF_C]: 'logs' } + } + } + }) + ) + + expect(state().tabsByWorktree[WT][2]).toEqual({ + id: 'c', + ptyId: 'pty-c', + worktreeId: WT, + createdAt: 1, + defaultTitle: 'Terminal 3', + launchAgent: 'claude', + title: 'Terminal 3', + customTitle: null, + color: null, + sortOrder: 2 + }) + expect(state().terminalLayoutsByTabId.c).toMatchObject({ + root: { type: 'leaf', leafId: LEAF_C }, + activeLeafId: LEAF_C, + titlesByLeafId: { [LEAF_C]: 'logs' } + }) + expect(state().unifiedTabsByWorktree[WT].at(-1)).toMatchObject({ + id: 'c', + entityId: 'c', + groupId: 'group', + contentType: 'terminal' + }) + expect(state().groupsByWorktree[WT][0]).toMatchObject({ + activeTabId: 'a', + tabOrder: ['a', 'b', '/wt/readme.md', 'c'] + }) + expect(state().ptyIdsByTabId).not.toHaveProperty('c') + }) + + it("shows main's saved title and colour on a tab main created, and keeps the window's on its own", () => { + state().setTabCustomTitle('a', 'renamed here') + apply( + slice(2, { + tabs: [...slice(2).tabs, row('c', { defaultTitle: 'Terminal 3' })], + presentation: { + a: { customTitle: 'stale in main', color: 'blue' }, + c: { customTitle: 'cli-made', color: 'red' } + } + }) + ) + + expect( + state().tabsByWorktree[WT].map(({ id, customTitle, color }) => [id, customTitle, color]) + ).toEqual([ + ['a', 'renamed here', '#f00'], + ['b', null, null], + ['c', 'cli-made', 'red'] + ]) + }) + + it('does not add a unified entry a tab already has under another id', () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + [WT]: [unified('a'), unified('b'), unified('u-c', { entityId: 'c' })] + } + }) + apply(slice(2, { tabs: [...slice(2).tabs, row('c')] })) + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'b', 'u-c']) + }) + + it('leaves a runtime-hosted tab under the same worktree id untouched', () => { + const runtimeTab = windowTab('remote-tab', { ptyId: 'remote:env@@handle' }) + useAppStore.setState({ + tabsByWorktree: { [WT]: [...state().tabsByWorktree[WT], runtimeTab] }, + unifiedTabsByWorktree: { + [WT]: [ + ...state().unifiedTabsByWorktree[WT], + unified('remote-tab', { executionHostId: 'runtime:env' }) + ] + } + }) + apply(slice(2, { tabs: [row('a')], layouts: { a: slice(2).layouts.a } })) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'remote-tab']) + expect(state().tabsByWorktree[WT][1]).toBe(runtimeTab) + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toContain('remote-tab') + }) + + it('replaces only this worktree’s sleeping records', () => { + const record = (paneKey: string, worktreeId: string): SleepingAgentSessionRecord => ({ + paneKey, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: paneKey }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1 + }) + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [`a:${LEAF_A}`]: record(`a:${LEAF_A}`, WT), + 'other:leaf': record('other:leaf', 'repo::/other') + } + }) + apply(slice(2, { sleeping: { [`b:${LEAF_B}`]: record(`b:${LEAF_B}`, WT) } })) + expect(Object.keys(state().sleepingAgentSessionsByPaneKey).sort()).toEqual([ + `b:${LEAF_B}`, + 'other:leaf' + ]) + }) + + it("reads the window's sleeping records a fixed number of times per batch, however many worktrees", () => { + const enumerationsFor = (worktreeCount: number): number => { + let enumerations = 0 + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: new Proxy>( + {}, + { + ownKeys: (target) => { + enumerations += 1 + return Reflect.ownKeys(target) + } + } + ) + }) + const worktreeIds = Array.from( + { length: worktreeCount }, + (_, index) => `repo::/wt-${worktreeCount}-${index}` + ) + state().applyTerminalTopologySlices( + worktreeIds.map((worktreeId) => + slice(10, { worktreeId, tabs: [row(`t-${worktreeId}`, { worktreeId })], layouts: {} }) + ) + ) + return enumerations + } + + const single = enumerationsFor(1) + expect(single).toBeGreaterThan(0) + expect(enumerationsFor(50)).toBe(single) + }) + + it('restores main’s slice at the applied seq over this window’s refused change', () => { + apply(slice(3)) + useAppStore.setState({ + tabsByWorktree: { [WT]: [...state().tabsByWorktree[WT], windowTab('refused')] } + }) + + state().restoreTerminalTopologySlice(slice(2)) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'b', 'refused']) + state().restoreTerminalTopologySlice(slice(3)) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'b']) + }) + + it('ignores a slice older than the last one applied', () => { + apply(slice(5, { tabs: [row('a')], layouts: { a: slice(5).layouts.a } })) + const before = state() + apply(slice(4)) + expect(state()).toBe(before) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a']) + }) + + it('leaves other worktrees and non-terminal tabs untouched', () => { + const other = windowTab('x', { worktreeId: 'repo::/other' }) + useAppStore.setState({ + tabsByWorktree: { ...state().tabsByWorktree, 'repo::/other': [other] } + }) + apply(slice(2, { tabs: [], layouts: {} })) + expect(state().tabsByWorktree['repo::/other'][0]).toBe(other) + expect(state().tabsByWorktree[WT]).toEqual([]) + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual(['/wt/readme.md']) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts new file mode 100644 index 000000000000..aa58d442c20b --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -0,0 +1,208 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + TerminalTabSavedPresentation, + TerminalTopologyLayout, + TerminalTopologySlice, + TerminalTopologyTabRow +} from '../../../../shared/terminal-topology-slice' +import { withTopologyRow } from '../../../../shared/terminal-topology-tab-row' +import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { sameStringRecord, terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' +import type { AppState } from '../types' +import { emptyLayoutSnapshot } from '../slices/terminal-helpers' +import type { TerminalSlice, TerminalStoreSet } from './terminal-state' +import { mirrorTerminalUnifiedTabs } from './terminal-topology-mirror-unified-tabs' +import { + isPendingTerminalTab, + isTerminalTabMirroredFromMain, + pendingAfterTerminalTopologySlice, + pendingTerminalLayoutRoot, + terminalStoreReady +} from './terminal-pending-panes' + +/** + * Main's row fields replace the window's; presentation stays, and so does `ptyId`. In the window it + * is the PTY the tab is attached to now (liveness), not main's persisted binding. + */ +function mirrorTabRow(current: TerminalTab, row: TerminalTopologyTabRow): TerminalTab { + const next = withTopologyRow(current, { ...row, ptyId: current.ptyId }) + return structuralValuesEqual(next, current) ? current : next +} + +/** + * A tab main created; its ptyId seeds the window's attachment, as a revealed tab's would, and the + * presentation main saved (a title the creator gave it) is what the window first shows. + */ +function newTabRow( + row: TerminalTopologyTabRow, + presentation: TerminalTabSavedPresentation | undefined, + sortOrder: number +): TerminalTab { + return { + ...row, + title: row.defaultTitle ?? 'Terminal', + customTitle: presentation?.customTitle ?? null, + color: presentation?.color ?? null, + sortOrder + } +} + +/** Pane titles are presentation: main's seed a layout new to the window, as a new tab's title does. */ +function mirrorLayout( + current: TerminalLayoutSnapshot | undefined, + layout: TerminalTopologyLayout +): TerminalLayoutSnapshot { + if (!current) { + const activeLeafId = resolvePtyBoundActiveLeafId({ + root: layout.root, + activeLeafId: null, + ptyIdsByLeafId: layout.ptyIdsByLeafId + }) + return { ...emptyLayoutSnapshot(), ...layout, activeLeafId } + } + if ( + terminalLayoutNodeEqual(current.root, layout.root) && + sameStringRecord(current.ptyIdsByLeafId, layout.ptyIdsByLeafId) + ) { + return current + } + const { ptyIdsByLeafId: _ptyIds, ...presentation } = current + const { titlesByLeafId: _mainTitles, ...topology } = layout + return { ...presentation, ...topology } +} + +/** One pass over the window's records for the whole batch, however many worktrees it holds. */ +function mirrorSleepingRecords( + current: Record, + slices: readonly TerminalTopologySlice[] +): Record { + const replaced = new Set(slices.map((slice) => slice.worktreeId)) + const next = Object.fromEntries( + Object.entries(current).filter(([, record]) => !replaced.has(record.worktreeId)) + ) + for (const slice of slices) { + Object.assign(next, slice.sleeping) + } + return structuralValuesEqual(next, current) ? current : next +} + +/** + * Main's slice replaces the worktree's terminal topology, except tabs still pending here (shown + * before main names them, or hidden before main drops them); everything else is the window's. + * Unchanged rows and layouts keep their identity, so an identical slice changes only the seq. + */ +function mirrorTerminalTopologySlice( + state: AppState, + slice: TerminalTopologySlice +): Partial { + const { worktreeId } = slice + const pending = pendingAfterTerminalTopologySlice(state.pendingTerminalPanes, slice) + const isPending = (tabId: string, change: 'add' | 'remove'): boolean => + isPendingTerminalTab(pending, worktreeId, tabId, change) + const currentTabs = state.tabsByWorktree[worktreeId] ?? [] + const rowById = new Map(slice.tabs.map((row) => [row.id, row])) + const kept = currentTabs.flatMap((tab) => { + const row = rowById.get(tab.id) + if (row) { + return [mirrorTabRow(tab, row)] + } + return !isTerminalTabMirroredFromMain(state, worktreeId, tab.id) || isPending(tab.id, 'add') + ? [tab] + : [] + }) + const currentIds = new Set(currentTabs.map((tab) => tab.id)) + const added = slice.tabs + .filter((row) => !currentIds.has(row.id) && !isPending(row.id, 'remove')) + .map((row, index) => newTabRow(row, slice.presentation[row.id], kept.length + index)) + const tabs = [...kept, ...added] + const tabsChanged = + tabs.length !== currentTabs.length || tabs.some((tab, index) => tab !== currentTabs[index]) + const keptIds = new Set(tabs.map((tab) => tab.id)) + const removedIds = currentTabs.filter((tab) => !keptIds.has(tab.id)).map((tab) => tab.id) + + const layouts = { ...state.terminalLayoutsByTabId } + for (const [tabId, layout] of Object.entries(slice.layouts)) { + if (!isPending(tabId, 'remove')) { + const heldRoot = pendingTerminalLayoutRoot(pending, worktreeId, tabId) + layouts[tabId] = mirrorLayout( + layouts[tabId], + heldRoot ? { ...layout, root: heldRoot } : layout + ) + } + } + for (const tab of added) { + layouts[tab.id] ??= emptyLayoutSnapshot() + } + for (const tabId of removedIds) { + delete layouts[tabId] + } + const layoutsChanged = + added.length + removedIds.length > 0 || + Object.keys(slice.layouts).some( + (tabId) => layouts[tabId] !== state.terminalLayoutsByTabId[tabId] + ) + + return { + ...(tabsChanged ? { tabsByWorktree: { ...state.tabsByWorktree, [worktreeId]: tabs } } : {}), + ...(layoutsChanged ? { terminalLayoutsByTabId: layouts } : {}), + ...mirrorTerminalUnifiedTabs(state, worktreeId, added, removedIds), + ...(pending !== state.pendingTerminalPanes ? { pendingTerminalPanes: pending } : {}), + terminalTopologySeqByWorktree: { + ...state.terminalTopologySeqByWorktree, + [worktreeId]: slice.publishSeq + } + } +} + +function mirrorTerminalTopologySlices( + state: AppState, + slices: readonly TerminalTopologySlice[] +): Partial { + let next = state + for (const slice of slices) { + next = { ...next, ...mirrorTerminalTopologySlice(next, slice) } + } + const sleeping = mirrorSleepingRecords(state.sleepingAgentSessionsByPaneKey, slices) + return sleeping === state.sleepingAgentSessionsByPaneKey + ? next + : { ...next, sleepingAgentSessionsByPaneKey: sleeping } +} + +const appliedSeq = (state: AppState, slice: TerminalTopologySlice): number => + state.terminalTopologySeqByWorktree[slice.worktreeId] ?? 0 + +export function createTerminalTopologyMirrorActions( + set: TerminalStoreSet +): Pick { + return { + applyTerminalTopologySlices: (slices) => { + set((s) => { + // Pushes and the startup pull race; the higher publishSeq is the newer slice. + const newer = slices.filter((slice) => slice.publishSeq > appliedSeq(s, slice)) + return newer.length > 0 ? mirrorTerminalTopologySlices(s, newer) : s + }) + }, + restoreTerminalTopologySlice: (slice) => { + set((s) => + slice.publishSeq >= appliedSeq(s, slice) ? mirrorTerminalTopologySlices(s, [slice]) : s + ) + } + } +} + +/** Resolves once this window has applied main's topology up to `publishSeq`. */ +export function terminalTopologyApplied( + subscribe: (listener: () => void) => () => void, + getState: () => Pick, + worktreeId: string, + publishSeq: number | undefined +): Promise { + return terminalStoreReady( + subscribe, + () => + publishSeq === undefined || + (getState().terminalTopologySeqByWorktree[worktreeId] ?? 0) >= publishSeq + ) +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts index a97af259580f..a6d858fbf47a 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts @@ -5,7 +5,7 @@ import { normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' -import { resolveTerminalLayoutPtyOwnershipTransfers } from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { resolveTerminalLayoutPtyOwnershipTransfers } from '../../../../shared/terminal-layout-pty-ownership' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import type { TerminalLayoutPtyOwnershipTransfer } from './workspace-terminal-hydration-patch' diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index 553b065c9923..f7fc3b18c28d 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -81,8 +81,21 @@ export function createWebWorkspaceSessionApi(): Partial { }) ) }, + // Why refused: web tabs and panes are created on the host through its session-tab RPCs. + createTerminalSurface: async () => ({ status: 'refused', reason: 'tab_not_held' }), // Why a no-op: web closes reach the host through its session-tab and terminal close RPCs. - closeTerminalSurface: async () => {}, + closeTerminalSurface: async () => ({}), + // Web clients keep sleeping records in their saved session. + commitTerminalSleepingRecords: async () => {}, + // Why refused: web layout edits reach the host through `session.tabs.updatePaneLayout`. + setTerminalLayout: async () => ({ status: 'refused', reason: 'tab_not_held' }), + // Why refused: web panes attach through the host, which records their bindings. + bindTerminalLeaf: async () => ({ status: 'refused', reason: 'not_bound' }), + // Web clients keep launch agents in their saved session. + clearTerminalLaunchAgent: async () => {}, + // Why empty: web clients follow the host's session tabs, not main's topology push. + getTerminalTopologySlices: async () => [], + onTerminalTopologyChanged: () => () => {}, // localStorage writes synchronously, so there is no deferred web flush. flush: async () => {}, readTerminalScrollback: () => null, diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index 9544b709f9ca..507b39433690 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -1,4 +1,5 @@ import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' +import type { WorkspaceSessionPatch } from './workspace-session-state-types' // Transient client-local fields are omitted, not merely unset: `recovery` is // this client's in-flight heal, stamped with this machine's clock, so the type @@ -65,6 +66,36 @@ export type RemoteWorkspaceObservedPatchResult = message?: string } +/** The CAS inputs an export to a host runs under: the host revision and observation this desktop last agreed on. */ +export type RemoteWorkspaceExportAuthority = { + revision: number + hostObservationToken: string +} + +/** A window's pull from one host: the session writes it imports and whether the import is whole. */ +export type RemoteWorkspacePeerImport = RemoteWorkspaceExportAuthority & { + targetId: string + /** + * `kept-local`: this desktop holds state the host lacks (tabs it never saw, unsent layout edits, + * or anything at all for a host never written), which must export. `conflict`: the window could + * not place every host tab, so this desktop must not export. + */ + outcome: 'synced' | 'kept-local' | 'conflict' + /** The import's patch to the target's own `ssh:` partition; empty when the host had nothing. */ + session: WorkspaceSessionPatch + /** The window's mirror (`publishSeq` per worktree) the patch was merged against. */ + mirroredTopologySeqByWorktree?: Record +} + +/** Main's report of one export it ran, for the window's sync status. */ +export type RemoteWorkspacePushStatusEvent = { + targetId: string + authority: RemoteWorkspaceExportAuthority + result: RemoteWorkspaceObservedPatchResult | null + /** Set instead of a result when the export threw. */ + error?: string +} + export const REMOTE_WORKSPACE_CHANGED_NOTIFICATION = 'workspace.changed' /** diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts b/src/shared/terminal-layout-pty-ownership-depth.test.ts similarity index 92% rename from src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts rename to src/shared/terminal-layout-pty-ownership-depth.test.ts index a22eb50b97f0..73f07b2c0d7d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts +++ b/src/shared/terminal-layout-pty-ownership-depth.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { TerminalPaneLayoutNode } from './terminal-tab-types' import { normalizeTerminalLayoutPtyOwnership } from './terminal-layout-pty-ownership' it('prunes deeply nested duplicate ownership without recursive stack growth', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts b/src/shared/terminal-layout-pty-ownership.ts similarity index 99% rename from src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts rename to src/shared/terminal-layout-pty-ownership.ts index 001be213fab1..b317e0894db1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts +++ b/src/shared/terminal-layout-pty-ownership.ts @@ -1,4 +1,4 @@ -import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { TerminalLayoutSnapshot } from './terminal-tab-types' import { collectLeafIds, pruneLeaves } from './terminal-pane-layout-tree' type TerminalLayoutPtyOwnershipNormalization = { diff --git a/src/shared/terminal-layout-set.ts b/src/shared/terminal-layout-set.ts new file mode 100644 index 000000000000..952350e730fd --- /dev/null +++ b/src/shared/terminal-layout-set.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { terminalPaneLayoutNodeSchema } from './workspace-session-schema' + +const TerminalLayoutSetRequestSchema = z.object({ + worktreeId: z.string().min(1).max(512), + tabId: z.string().min(1).max(512), + root: terminalPaneLayoutNodeSchema +}) + +/** A user's geometry edit to one tab: divider positions, split directions or pane order, same panes. */ +export type TerminalLayoutSetRequest = z.infer + +export type TerminalLayoutSetResult = + | { status: 'committed' } + | { + status: 'refused' + reason: 'invalid_request' | 'home_unresolved' | 'tab_not_held' | 'leaves_differ' + } + +/** Null for anything malformed. */ +export function parseTerminalLayoutSetRequest(value: unknown): TerminalLayoutSetRequest | null { + const parsed = TerminalLayoutSetRequestSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/shared/terminal-leaf-bind.ts b/src/shared/terminal-leaf-bind.ts new file mode 100644 index 000000000000..c35a824fd1b6 --- /dev/null +++ b/src/shared/terminal-leaf-bind.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { isTerminalLeafId } from './stable-pane-id' + +const id = z.string().min(1).max(512) +const TerminalLeafBindRequestSchema = z.object({ + worktreeId: id, + tabId: id, + leafId: id.refine((leafId): boolean => isTerminalLeafId(leafId)), + ptyId: id +}) + +/** A live PTY the window adopted onto a pane main already holds. */ +export type TerminalLeafBindRequest = z.infer + +export type TerminalLeafBindResult = + | { status: 'bound' } + | { status: 'refused'; reason: 'invalid_request' | 'home_unresolved' | 'not_bound' } + +/** Null for anything malformed. */ +export function parseTerminalLeafBindRequest(value: unknown): TerminalLeafBindRequest | null { + const parsed = TerminalLeafBindRequestSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/shared/terminal-leaf-move.ts b/src/shared/terminal-leaf-move.ts index ddf3a8236194..2129bac8a164 100644 --- a/src/shared/terminal-leaf-move.ts +++ b/src/shared/terminal-leaf-move.ts @@ -17,7 +17,12 @@ export type TerminalLeafMoveResult = | { status: 'not_held' } | { status: 'refused' - reason: 'invalid_request' | 'target_tab_exists' | 'pty_mismatch' | 'leaf_in_other_tab' + reason: + | 'invalid_request' + | 'home_unresolved' + | 'target_tab_exists' + | 'pty_mismatch' + | 'leaf_in_other_tab' } /** The moved pane's key before and after; only valid requests reach here. */ diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-tree.ts b/src/shared/terminal-pane-layout-tree.ts similarity index 95% rename from src/renderer/src/components/terminal-pane/terminal-pane-layout-tree.ts rename to src/shared/terminal-pane-layout-tree.ts index 5e36a9904ba4..b89749c812d8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-tree.ts +++ b/src/shared/terminal-pane-layout-tree.ts @@ -1,4 +1,4 @@ -import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { TerminalPaneLayoutNode } from './terminal-tab-types' export function collectLeafIds(node: TerminalPaneLayoutNode | null | undefined): string[] { if (!node) { diff --git a/src/shared/terminal-pane-placement.test.ts b/src/shared/terminal-pane-placement.test.ts index 1ff106cf846c..3c2b3fe5e656 100644 --- a/src/shared/terminal-pane-placement.test.ts +++ b/src/shared/terminal-pane-placement.test.ts @@ -20,7 +20,8 @@ describe('parseTerminalPanePlacement', () => { customTitle: null, color: '#f97316', createdAt: 1, - viewMode: 'chat' + viewMode: 'chat', + forceHostRuntime: true } } const split = { diff --git a/src/shared/terminal-pane-placement.ts b/src/shared/terminal-pane-placement.ts index 9627f6d9edc6..648e755ad5be 100644 --- a/src/shared/terminal-pane-placement.ts +++ b/src/shared/terminal-pane-placement.ts @@ -15,7 +15,7 @@ const NewTabRow = terminalTabSchema launchAgent: true, viewMode: true }) - .extend({ shellOverride: z.string().optional() }) + .extend({ shellOverride: z.string().optional(), forceHostRuntime: z.boolean().optional() }) .partial() // Optional fields degrade alone: a malformed one is dropped, not the whole placement. @@ -39,7 +39,7 @@ const SplitPlacement = z.object({ // An existing tab whose layout is still empty. const RootPlacement = z.object({ kind: z.literal('root') }) -const TerminalPanePlacementSchema = z.discriminatedUnion('kind', [ +export const TerminalPanePlacementSchema = z.discriminatedUnion('kind', [ NewTabPlacement, SplitPlacement, RootPlacement diff --git a/src/shared/terminal-surface-create.ts b/src/shared/terminal-surface-create.ts new file mode 100644 index 000000000000..eb902aa3b6f0 --- /dev/null +++ b/src/shared/terminal-surface-create.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { isTerminalLeafId } from './stable-pane-id' +import { TerminalPanePlacementSchema } from './terminal-pane-placement' + +const id = z.string().min(1).max(512) +const TerminalSurfaceCreateRequestSchema = z.object({ + worktreeId: id, + tabId: id, + /** Absent for a tab created before its first pane exists. */ + leafId: id.refine((leafId): boolean => isTerminalLeafId(leafId)).optional(), + placement: TerminalPanePlacementSchema +}) + +/** A tab or pane the window created, recorded unbound in main before any process starts. */ +export type TerminalSurfaceCreateRequest = z.infer + +export type TerminalSurfaceCreateResult = + | { status: 'committed' } + | { + status: 'refused' + reason: 'invalid_request' | 'home_unresolved' | 'tab_not_held' | 'parent_missing' + } + +/** Null for anything malformed. */ +export function parseTerminalSurfaceCreateRequest( + value: unknown +): TerminalSurfaceCreateRequest | null { + const parsed = TerminalSurfaceCreateRequestSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index 689aee495462..c4b367a5599e 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -57,6 +57,7 @@ export type TerminalTabRecoveryLedger = { // ─── Terminal Tab (legacy — used by persistence and TerminalContentSlice) ─ export type TerminalTab = { id: string + /** Main: the row's persisted PTY (a wake hint). Window: the PTY attached now; never main's binding. */ ptyId: string | null worktreeId: string title: string diff --git a/src/shared/terminal-topology-slice.ts b/src/shared/terminal-topology-slice.ts index 79c0cc0b26a6..59a13904e57e 100644 --- a/src/shared/terminal-topology-slice.ts +++ b/src/shared/terminal-topology-slice.ts @@ -17,6 +17,9 @@ export type TerminalTopologyTabRow = Pick< | 'quickCommandLabel' > +/** The presentation main last saved for a tab; a window shows it on a tab it has not shown yet. */ +export type TerminalTabSavedPresentation = Pick + export type TerminalTopologyLayout = Pick< TerminalLayoutSnapshot, 'root' | 'ptyIdsByLeafId' | 'titlesByLeafId' @@ -30,6 +33,16 @@ export type TerminalTopologySlice = { publishSeq: number revision: number tabs: TerminalTopologyTabRow[] + presentation: Record layouts: Record sleeping: Record } + +/** On a reply whose write main publishes: the publishSeq of the push holding it. */ +export type TerminalTopologyReply = { publishSeq?: number } + +/** A window's own sleeping-agent record changes: records it now holds, and pane keys it dropped. */ +export type TerminalSleepingRecordChanges = { + sleep: Record + wake: string[] +} diff --git a/src/shared/terminal-topology-tab-row.ts b/src/shared/terminal-topology-tab-row.ts new file mode 100644 index 000000000000..27866fde6cd4 --- /dev/null +++ b/src/shared/terminal-topology-tab-row.ts @@ -0,0 +1,28 @@ +import type { TerminalTab } from './terminal-tab-types' +import type { TerminalTopologyTabRow } from './terminal-topology-slice' + +const OPTIONAL_ROW_FIELDS = [ + 'launchAgent', + 'defaultTitle', + 'shellOverride', + 'startupCwd', + 'forceHostRuntime', + 'quickCommandLabel' +] as const satisfies readonly (keyof TerminalTopologyTabRow)[] + +type _UnlistedRowField = Exclude< + keyof TerminalTopologyTabRow, + 'id' | 'ptyId' | 'worktreeId' | 'createdAt' | (typeof OPTIONAL_ROW_FIELDS)[number] +> +void (true satisfies [_UnlistedRowField] extends [never] ? true : never) + +/** `tab` with main's row fields; an optional one the row lacks is dropped, not kept from `tab`. */ +export function withTopologyRow(tab: TerminalTab, row: TerminalTopologyTabRow): TerminalTab { + const next: TerminalTab = { ...tab, ...row } + for (const field of OPTIONAL_ROW_FIELDS) { + if (!(field in row)) { + delete next[field] + } + } + return next +} diff --git a/src/shared/workspace-session-stranded-partition-adoption.ts b/src/shared/workspace-session-stranded-partition-adoption.ts index c36d35b4bef4..e154145203f3 100644 --- a/src/shared/workspace-session-stranded-partition-adoption.ts +++ b/src/shared/workspace-session-stranded-partition-adoption.ts @@ -263,7 +263,7 @@ function entryWorkspaceResolver( } /** The base without any rows for these workspaces, routed by the same indexes the split uses. */ -function withoutWorkspaces( +export function withoutWorkspaces( base: WorkspaceSessionState, workspaceIds: ReadonlySet ): WorkspaceSessionState { diff --git a/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts b/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts index 7ac0ec897a7f..748f3b1ebd7f 100644 --- a/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts +++ b/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts @@ -154,7 +154,8 @@ async function applySnapshot(snapshot: RemoteWorkspaceObservedSnapshot): Promise isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => useAppStore.getState().retryDirectSshTargetPanes(authority) + finalizeHydratedTerminals: () => useAppStore.getState().retryDirectSshTargetPanes(authority), + importPeerTopology: async () => {} }) expect(result).toBe('applied') } diff --git a/tests/e2e/local-cli-terminal-retention.spec.ts b/tests/e2e/local-cli-terminal-retention.spec.ts index 19eceb6e868c..a17f7a9a78e3 100644 --- a/tests/e2e/local-cli-terminal-retention.spec.ts +++ b/tests/e2e/local-cli-terminal-retention.spec.ts @@ -28,6 +28,7 @@ import { RuntimeClient } from '../../src/cli/runtime-client' import { makePaneKey } from '../../src/shared/stable-pane-id' import { expect, test } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' +import { splitActiveTerminalPane } from './helpers/terminal' import { createHostCliTerminal, createHostRendererTerminalTab, @@ -176,36 +177,70 @@ test('keeps a locally created CLI terminal, and never resumes it as a ghost', as await proveSameLivePty(call, unrelated, 'unrelated-alive') const cliPaneKey = makePaneKey(cli.tabId, cli.leafId) - // The control: a record whose pane is gone, i.e. what the pruned CLI tab - // would leave behind. Its replay is the proof the sweep ran at all. - const absentPaneKey = makePaneKey(randomUUID(), randomUUID()) + // The control: a record whose pane is gone, so its replay is the proof the + // sweep ran at all. Main keeps a record only beside a tab it holds, so its + // tab stays; split, so neither of its live PTYs is taken for the gone pane. + await splitActiveTerminalPane(orcaPage, 'vertical') + await expect + .poll( + () => + orcaPage.evaluate( + (tabId) => window.__store?.getState().ptyIdsByTabId[tabId]?.length ?? 0, + secondRendererTabId + ), + { timeout: 30_000, message: 'the control tab never split into two live panes' } + ) + .toBe(2) + const absentPaneKey = makePaneKey(secondRendererTabId, randomUUID()) // Why: a real agent reports its provider session over the hook server; // writing the same store entry keeps this hermetic on the identical path. await orcaPage.evaluate( - ({ panes, worktreeId }) => { - for (const { paneKey, sessionId } of panes) { - window.__store?.getState().setAgentStatus( - paneKey, - { - state: 'working', - prompt: 'local cli retention', - agentType: 'claude' - }, - 'Claude', - undefined, - { worktreeId }, - { providerSession: { key: 'session_id', id: sessionId } } - ) - } + ({ paneKey, sessionId, worktreeId }) => { + window.__store?.getState().setAgentStatus( + paneKey, + { + state: 'working', + prompt: 'local cli retention', + agentType: 'claude' + }, + 'Claude', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: sessionId } } + ) }, - { - panes: [ - { paneKey: cliPaneKey, sessionId: CLI_PANE_SESSION_ID }, - { paneKey: absentPaneKey, sessionId: ABSENT_PANE_SESSION_ID } - ], - worktreeId - } + { paneKey: cliPaneKey, sessionId: CLI_PANE_SESSION_ID, worktreeId } + ) + await expect + .poll( + () => + orcaPage.evaluate( + (paneKey) => + window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] !== undefined, + cliPaneKey + ), + { timeout: 30_000, message: 'the seeded provider session never became a sleeping record' } + ) + .toBe(true) + // Why committed directly: a closed pane reports no status, so its record is + // all that is left of it; a live status would claim the session instead. + await orcaPage.evaluate( + ({ cliPaneKey, absentPaneKey, tabId, sessionId }) => { + const template = window.__store!.getState().sleepingAgentSessionsByPaneKey[cliPaneKey]! + return window.api.session.commitTerminalSleepingRecords({ + sleep: { + [absentPaneKey]: { + ...template, + paneKey: absentPaneKey, + tabId, + providerSession: { key: 'session_id', id: sessionId } + } + }, + wake: [] + }) + }, + { cliPaneKey, absentPaneKey, tabId: secondRendererTabId, sessionId: ABSENT_PANE_SESSION_ID } ) await expect .poll( diff --git a/tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts b/tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts index 03e72adfd98a..440fce2f966a 100644 --- a/tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts +++ b/tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts @@ -134,6 +134,8 @@ async function resumeAcrossRestart( .toBe(remote.worktreeId) await waitForActiveTerminalManager(firstLaunch.page, 60_000) const descriptor = await waitForActivePaneHookDescriptor(firstLaunch.page, 60_000) + // A real agent reports from inside a bound PTY; main holds the tab (and its sleeping record) from then. + await waitForActivePanePtyId(firstLaunch.page, 60_000) // Why seeded rather than driven by a real agent: a real `claude` run needs an install and auth // in the container. This is the same store entry the hook server writes, so the capture, diff --git a/tests/e2e/terminal-layout-parity-declared-differences.ts b/tests/e2e/terminal-layout-parity-declared-differences.ts index e779d83f1cdf..c4d1c91925f2 100644 --- a/tests/e2e/terminal-layout-parity-declared-differences.ts +++ b/tests/e2e/terminal-layout-parity-declared-differences.ts @@ -7,7 +7,51 @@ import type { * Differences from main this branch is allowed, each tied to the named bug it fixes. Keep empty * on an inert PR. Paths are prefixes of the runner's report paths, e.g. `[0].persisted.local`. */ -export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParityDifference[] = [] +export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParityDifference[] = [ + // The core's mechanism: main alone authors terminal topology, so a window save no longer erases + // main's per-pane incarnations or the fence that guards them. Saved-profile fields only; nothing + // on screen differs. Close and move scenarios save exactly what main does. + ...[ + 'create-tab', + 'split-right-down', + 'reorder-panes', + 'cli-split', + 'restart-restore', + 'folder-workspace', + 'setup-split-first-activation', + 'sleep-quit-resume' + ].map((scenario) => ({ + scenario, + bugId: 'STA-9417', + paths: ['[0]', '[1]', '[2]'].flatMap((checkpoint) => [ + `${checkpoint}.persisted.local.terminalTopologyRevisionByRepoId`, + `${checkpoint}.persisted.local.terminalPtyIncarnationsByPaneKey` + ]), + reason: 'Main keeps the topology fields it authors; a window save no longer rewrites them.' + })), + { + // The bug itself: main mints a second tab for the setup terminal (or leaves it with no pane); + // the extra tab renumbers every later id, so the whole checkpoint differs. + scenario: 'setup-split-first-activation', + bugId: 'STA-9417', + paths: ['[0].renderer', '[0].persisted.local'], + reason: + 'Main mints a duplicate tab for the setup split on first activation; the core keeps one ' + + "tab whose split holds both terminals, and keeps main's row (start folder) for that tab." + } +] /** Paths main itself does not reproduce; reported but not failed. Remove an entry once main is fixed. */ -export const TERMINAL_LAYOUT_PARITY_UNSTABLE_ON_MAIN: readonly UnstableOnMainPath[] = [] +export const TERMINAL_LAYOUT_PARITY_UNSTABLE_ON_MAIN: readonly UnstableOnMainPath[] = [ + { + // The window lists a tab's PTYs, and on main saves its row, in the order the woken panes' + // respawns return; main returns the first pane first only most of the time. + scenario: 'sleep-quit-resume', + paths: [ + '[1].renderer.ptyIdsByTabId', + '[1].renderer.tabsByWorktree.#4::[0].ptyId', + '[1].persisted.local.tabsByWorktree.#4::[0].ptyId' + ], + evidence: 'main 61de2d8ec85 saved the second pane as the row in 5 of 15 runs on macOS' + } +] diff --git a/tests/e2e/terminal-layout-parity-snapshot.ts b/tests/e2e/terminal-layout-parity-snapshot.ts index e310a5d01be5..86d9afcdebc5 100644 --- a/tests/e2e/terminal-layout-parity-snapshot.ts +++ b/tests/e2e/terminal-layout-parity-snapshot.ts @@ -104,9 +104,17 @@ class IdLabeler { walk(root) while (pending.length > 0) { const ready = pending.filter(([key]) => this.knows(key)) - // Fallback for ids seen only as keys: raw order, the one place a label can depend on value. + // Fallback for ids seen only as keys: masked key first (unlabeled ids masked), then raw order, + // the one place a label can depend on value. + const masked = (key: string): string => key.replace(new RegExp(ID_SOURCE, 'gi'), '#') const next = - ready.length > 0 ? ready : [pending.toSorted((a, b) => a[0].localeCompare(b[0]))[0]!] + ready.length > 0 + ? ready + : [ + pending.toSorted( + (a, b) => masked(a[0]).localeCompare(masked(b[0])) || a[0].localeCompare(b[0]) + )[0]! + ] const rank = (key: string): string => this.replaceIn(key).replace(/\d+/g, (n) => n.padStart(6, '0')) next.sort((a, b) => rank(a[0]).localeCompare(rank(b[0]))) diff --git a/tests/e2e/terminal-layout-parity.spec.ts b/tests/e2e/terminal-layout-parity.spec.ts index 242574f4e66a..383d91b48b57 100644 --- a/tests/e2e/terminal-layout-parity.spec.ts +++ b/tests/e2e/terminal-layout-parity.spec.ts @@ -4,6 +4,7 @@ * this file against two builds and diffs the outputs; see tests/e2e/AGENTS.md. */ +import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -27,8 +28,10 @@ import { seededRepoPathOrSkip } from './helpers/terminal-restart-persistence' import { createTerminalTabFromMenu, SORTABLE_TAB } from './helpers/terminal-tab-menu' +import { activateWorkspaceByClick, sleepWorkspaceViaSidebar } from './helpers/slept-workspace-probe' import { RuntimeClient } from '../../src/cli/runtime/client' import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types' +import type { RuntimeWorktreeCreateResult } from '../../src/shared/runtime-worktree-contracts' import { TERMINAL_LAYOUT_PARITY_OUT_ENV, type RawParityCheckpoint, @@ -175,10 +178,15 @@ type ScenarioSetup = { type ParityScenario = { id: string - setup?: (page: Page) => Promise + setup?: (page: Page, userDataDir: string) => Promise journey: (journey: Journey) => Promise /** Relaunch (`times`, default once) and capture each restored renderer and the next save. */ - restart?: { expectedPaneCount: number; times?: number } + restart?: { + expectedPaneCount: number + times?: number + /** How the user gets back to the worktree; default: it is still the active one. */ + reopen?: (page: Page, worktreeId: string) => Promise + } } /** Quit through the shared helper and report how the process ended; a forced kill skips the final save. */ @@ -208,7 +216,7 @@ async function runScenario(testInfo: TestInfo, scenario: ParityScenario): Promis const first = await session.launch() app = first.app setup = scenario.setup - ? await scenario.setup(first.page) + ? await scenario.setup(first.page, session.userDataDir) : await bootstrapFirstLaunch(first.page, repoPath) await waitForBoundPanes(first.page, 1) const journey = { @@ -224,11 +232,15 @@ async function runScenario(testInfo: TestInfo, scenario: ParityScenario): Promis const persisted = readPersistedSessions(session.userDataDir) checkpoints.push({ label: 'after-quit', renderer, persisted, exit }) - const { expectedPaneCount = 0, times: restarts = 1 } = scenario.restart ?? { times: 0 } + const { + expectedPaneCount = 0, + times: restarts = 1, + reopen = bootstrapRestoredLaunch + } = scenario.restart ?? { times: 0 } for (let restart = 1; restart <= restarts; restart += 1) { const next = await session.launch() app = next.app - await bootstrapRestoredLaunch(next.page, setup.worktreeId) + await reopen(next.page, setup.worktreeId) await waitForBoundPanes(next.page, expectedPaneCount) const restored = await readSettledRendererLayout(next.page) const restartExit = await quitAndReadExit(session, app) @@ -290,6 +302,170 @@ async function addFolderWorkspace(page: Page): Promise { return { worktreeId: worktreeId!, pathLabels: { [folderPath]: '' }, cleanup } } +/** + * Main reveals the split to the window only if the window's runtime graph already lists the first + * terminal. The natural repro lost that race; an idle e2e window wins it in ~80 ms, so hold the + * window busy from just after the first reveal until well past main's split. + */ +async function stallRendererOnFirstSetupTab(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store! + const unsubscribe = store.subscribe((state) => { + const revealed = Object.entries(state.tabsByWorktree).some( + ([worktreeId, tabs]) => worktreeId.endsWith('parity-setup') && (tabs ?? []).length > 0 + ) + if (!revealed) { + return + } + unsubscribe() + // After the reveal's own task, before the graph sync it scheduled. + setTimeout(() => { + const until = performance.now() + 3_000 + while (performance.now() < until) { + // Busy, as a loaded renderer is: no timer or IPC runs. + } + }, 0) + }) + }) +} + +/** + * STA-9417's shape: the CLI creates a worktree whose setup script runs in a split of its first + * terminal, with the window open on another worktree; the user then opens it for the first time. + */ +async function addHostCreatedSetupSplitWorktree( + page: Page, + userDataDir: string +): Promise { + const { worktreeId } = await bootstrapFirstLaunch(page, seededRepoPathOrSkip()) + const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-parity-setup-'))) + const git = (...args: string[]): void => { + execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) + } + git('init', '-b', 'main') + git('config', 'user.email', 'e2e@test.local') + git('config', 'user.name', 'E2E Test') + writeFileSync(path.join(repoPath, 'orca.yaml'), 'scripts:\n setup: echo SETUP_COMPLETE\n') + git('add', '-A') + git('commit', '-m', 'setup hook') + await page.evaluate(() => + window.__store!.getState().updateSettings({ setupScriptLaunchMode: 'split-vertical' }) + ) + await stallRendererOnFirstSetupTab(page) + const client = new RuntimeClient(userDataDir, 30_000) + const added = await client.call<{ repo: { id: string } }>('repo.add', { + path: repoPath, + kind: 'git' + }) + const created = await client.call('worktree.create', { + repo: `id:${added.result.repo.id}`, + name: 'parity-setup', + noParent: true, + activate: false, + setupDecision: 'run' + }) + const worktreePath = created.result.worktree.path + return { + worktreeId, + pathLabels: { [repoPath]: '', [worktreePath]: '' }, + cleanup: () => { + rmSync(worktreePath, { recursive: true, force: true }) + rmSync(repoPath, { recursive: true, force: true }) + } + } +} + +async function openSetupSplitWorktree({ page, userDataDir }: Journey): Promise { + let setupWorktreeId: string | undefined + await expect + .poll(async () => { + setupWorktreeId = await page.evaluate(async () => { + const store = window.__store! + await store.getState().fetchRepos() + for (const repo of store.getState().repos) { + await store.getState().fetchWorktrees(repo.id) + } + return Object.values(store.getState().worktreesByRepo) + .flat() + .find((worktree) => worktree.branch === 'refs/heads/parity-setup')?.id + }) + return setupWorktreeId + }) + .toBeDefined() + const client = new RuntimeClient(userDataDir, 30_000) + // Main has spawned the first terminal and the setup split before the user opens the worktree. + let hostPtyIds: string[] = [] + await expect + .poll(async () => { + const listed = await client + .call('terminal.list', { worktree: `id:${setupWorktreeId!}` }) + .catch(() => null) + hostPtyIds = (listed?.result.terminals ?? []).flatMap((terminal) => + terminal.ptyId ? [terminal.ptyId] : [] + ) + return hostPtyIds.length + }) + .toBe(2) + await activateWorkspaceByClick(page, setupWorktreeId!) + await waitForActiveTerminalManager(page) + // Main either mints a second tab for the setup terminal or leaves it with no pane at all, so + // wait for every host terminal to have a pane but capture whichever state activation settles in. + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const allBound = await page.evaluate( + ([worktreeId, ptyIds]) => { + const state = window.__store!.getState() + const bound = new Set( + (state.tabsByWorktree[worktreeId] ?? []).flatMap((tab) => + Object.values(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) + ) + ) + return ptyIds.every((ptyId) => bound.has(ptyId)) + }, + [setupWorktreeId!, hostPtyIds] as const + ) + if (allBound) { + return + } + await page.waitForTimeout(250) + } +} + +/** A slept agent pane, through the user's sidebar Sleep, so the quit and relaunch resume it. */ +async function sleepAgentPane({ page, worktreeId }: Journey): Promise { + await splitActiveTerminalPane(page, 'vertical') + await waitForBoundPanes(page, 2) + await page.evaluate((id) => { + const state = window.__store!.getState() + const tabId = state.activeTabId! + const manager = window.__paneManagers!.get(tabId)! + const leafId = manager.getLeafId(manager.getActivePane()!.id)! + state.setAgentStatus( + `${tabId}:${leafId}`, + { state: 'working', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: id }, + { providerSession: { key: 'session_id', id: 'parity-sleep-session' } } + ) + }, worktreeId) + await sleepWorkspaceViaSidebar(page, worktreeId) + await expect + .poll(() => + page.evaluate((id) => { + const state = window.__store!.getState() + const slept = Object.values(state.sleepingAgentSessionsByPaneKey).some( + (record) => record.worktreeId === id + ) + const live = (state.tabsByWorktree[id] ?? []).some( + (tab) => (state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0 + ) + return slept && !live + }, worktreeId) + ) + .toBe(true) +} + /** * The user's close-pane chord, so the close reaches main through the real commit path; driving * PaneManager.closePane directly leaves main to learn of it from the PTY exit, which races quit. @@ -349,6 +525,14 @@ async function splitThenNewTab(page: Page): Promise { await waitForBoundPanes(page, 1) } +/** A slept worktree is not active after relaunch; the user's click wakes it and resumes the agent. */ +async function wakeByClick(page: Page, worktreeId: string): Promise { + await waitForSessionReady(page) + await activateWorkspaceByClick(page, worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) +} + const SCENARIOS: ParityScenario[] = [ { id: 'create-tab', @@ -431,6 +615,16 @@ const SCENARIOS: ParityScenario[] = [ id: 'folder-workspace', setup: addFolderWorkspace, journey: ({ page }) => splitThenNewTab(page) + }, + { + id: 'setup-split-first-activation', + setup: addHostCreatedSetupSplitWorktree, + journey: openSetupSplitWorktree + }, + { + id: 'sleep-quit-resume', + journey: sleepAgentPane, + restart: { expectedPaneCount: 2, reopen: wakeByClick } } ] diff --git a/tests/e2e/terminal-tab-title-and-place.spec.ts b/tests/e2e/terminal-tab-title-and-place.spec.ts new file mode 100644 index 000000000000..f96e0927e79c --- /dev/null +++ b/tests/e2e/terminal-tab-title-and-place.spec.ts @@ -0,0 +1,136 @@ +/** + * Main writes the terminal layout and windows mirror it, so a tab main creates or edits must + * reach the window with the title and place the user would see on the old window-written path: + * a CLI `--title`, and a split pane closed on the desktop while a web client is paired. + */ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { runCompiledOrcaCli } from './helpers/compiled-orca-cli' +import { waitForPairedClientWorktree } from './helpers/paired-client-host-session' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount +} from './helpers/terminal' +import { createTerminalTabFromMenu, SORTABLE_TAB } from './helpers/terminal-tab-menu' + +type TabRow = { id: string; title: string } + +async function activeWorktreeId(page: Page): Promise { + await waitForSessionReady(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const worktreeId = await page.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!worktreeId) { + throw new Error('No active worktree') + } + return worktreeId +} + +/** The tab bar as the user reads it: order and shown title. */ +function readTabs(page: Page, worktreeId: string): Promise { + return page.evaluate((wt) => { + const state = window.__store!.getState() + const key = + Object.keys(state.tabsByWorktree).find( + (candidate) => candidate === wt || candidate.includes(wt) + ) ?? wt + return (state.tabsByWorktree[key] ?? []).map((tab) => ({ + id: tab.id, + title: tab.customTitle ?? tab.title + })) + }, worktreeId) +} + +test('a CLI-created terminal shows the title it was created with', async ({ + electronApp, + orcaPage +}) => { + const worktreeId = await activeWorktreeId(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + + const created = await runCompiledOrcaCli(userDataDir, [ + 'terminal', + 'create', + '--worktree', + `id:${worktreeId}`, + '--title', + 'cli-made', + '--json' + ]) + expect(created.code).toBe(0) + + await expect + .poll(async () => (await readTabs(orcaPage, worktreeId)).map((tab) => tab.title), { + timeout: 15_000 + }) + .toContain('cli-made') + const listed = await runCompiledOrcaCli(userDataDir, [ + 'terminal', + 'list', + '--worktree', + `id:${worktreeId}`, + '--json' + ]) + expect(listed.stdout).toContain('"cli-made"') +}) + +test('closing a split pane on the desktop keeps its tab in place while a web client is paired', async ({ + electronApp, + orcaPage +}) => { + test.skip(process.env.ORCA_E2E_WEB_CLIENT !== '1', 'Needs the paired web client build') + const worktreeId = await activeWorktreeId(orcaPage) + const client = await launchPairedWebClient( + electronApp, + await createRuntimeDesktopPairingOffer(orcaPage) + ) + try { + await waitForPairedClientWorktree(client.page, worktreeId) + await client.page.evaluate((wt) => { + const state = window.__store!.getState() + state.setActiveView?.('terminal') + state.setActiveWorktree(wt) + }, worktreeId) + + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2, 30_000) + await createTerminalTabFromMenu(orcaPage) + await waitForPaneCount(orcaPage, 1, 30_000) + const before = await readTabs(orcaPage, worktreeId) + expect(before).toHaveLength(2) + await expect + .poll(() => readTabs(client.page, worktreeId).then((tabs) => tabs.map((tab) => tab.title)), { + timeout: 15_000 + }) + .toEqual(before.map((tab) => tab.title)) + + await orcaPage.locator(SORTABLE_TAB).first().click() + await waitForPaneCount(orcaPage, 2, 30_000) + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + const confirm = orcaPage.getByRole('button', { name: 'Stop and Close' }) + if (await confirm.isVisible({ timeout: 1_000 }).catch(() => false)) { + await confirm.click() + } + await waitForPaneCount(orcaPage, 1, 30_000) + + // Why a hold: the regression withdrew the tab when the closed pane's PTY exited, then + // re-added it last and untitled, seconds after the close. + await orcaPage.waitForTimeout(5_000) + expect(await readTabs(orcaPage, worktreeId)).toEqual(before) + await expect + .poll(() => readTabs(client.page, worktreeId).then((tabs) => tabs.map((tab) => tab.title)), { + timeout: 15_000 + }) + .toEqual(before.map((tab) => tab.title)) + } finally { + await client.dispose() + } +})