diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index ff475405a5e..731521ea8d6 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -26,7 +26,7 @@ on: # Node, so any change to the daemon bundle graph or the main build must # re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI). - 'electron.vite.config.ts' - - 'build-plugins/**' + - 'config/build-plugins/**' - 'src/main/daemon/**' - 'native/computer-use-macos/**' - 'native/computer-use-linux/**' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c2c3119456b..7b702db9e55 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -233,8 +233,32 @@ jobs: --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ + --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + cross-version-wire: + name: cross-version wire compatibility + runs-on: ubuntu-latest + + steps: + # Why fetch-depth 0: the harness extracts the newest release tag to skew + # current code against it. The default shallow clone has no tags, which is + # why this cannot ride along in the sharded `test` job. + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + + # A path filter that matches nothing exits 1 ("No test files found"), so this + # lane cannot report success while running zero tests. + - name: Old/new client and server terminal journey + run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts + package: name: package runs-on: ubuntu-latest diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index 0098932a1ca..fb1d63a440a 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -77,7 +77,7 @@ jobs: '!config/reliability-gates.jsonc', '!config/max-lines-baseline.txt', '!config/vitest.config.ts', - 'build-plugins/**', + 'config/build-plugins/**', 'native/**', 'resources/**', 'electron.vite.config.ts', diff --git a/.gitignore b/.gitignore index b1de9292f1a..fc40e495e7a 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ docs/** !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md !docs/reference/relay-grace-time-reconfiguration.md +!docs/reference/remote-wire-compatibility.md !docs/reference/windows-setup-shell.md # Stably CLI (only docs/ are tracked) @@ -135,3 +136,6 @@ src/renderer/src/i18n/locales/.es-catalog-cache.json # Bench result JSONs are working artifacts tests/tools/benchmarks/results/terminal-pipeline-*.json + +# Old release trees the cross-version wire harness extracts on demand +tests/e2e/.cross-version-checkouts/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 2473b070017..0f679f17d52 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,5 +150,5 @@ } } ], - "ignorePatterns": ["**/node_modules", "**/dist", "**/out"] + "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] } diff --git a/AGENTS.md b/AGENTS.md index 6bc54a6f5c0..67bcd37a538 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,10 @@ All changes must consider the SSH use case. Don't assume local-only execution. All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree. +## Remote Wire Compatibility + +Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change. + ## Git Binary Compatibility Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md). diff --git a/build-plugins/bootstrap-fatal-exit-banner.ts b/config/build-plugins/bootstrap-fatal-exit-banner.ts similarity index 97% rename from build-plugins/bootstrap-fatal-exit-banner.ts rename to config/build-plugins/bootstrap-fatal-exit-banner.ts index de4da1319e5..9233d4327e1 100644 --- a/build-plugins/bootstrap-fatal-exit-banner.ts +++ b/config/build-plugins/bootstrap-fatal-exit-banner.ts @@ -1,4 +1,4 @@ -import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../src/main/startup/bootstrap-fatal-exit-guard' +import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard' export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG' export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log' const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144 diff --git a/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts similarity index 100% rename from build-plugins/plain-node-entry-guard.ts rename to config/build-plugins/plain-node-entry-guard.ts diff --git a/config/knip.json b/config/knip.json index cb929266ff3..c5b96b5a87d 100644 --- a/config/knip.json +++ b/config/knip.json @@ -24,7 +24,7 @@ "src/relay/relay.ts", "src/relay/wsl-agent-hook-relay.ts", "config/scripts/**/*.{mjs,cjs,js,ts}", - "build-plugins/**/*.ts", + "config/build-plugins/**/*.ts", "electron.vite.config.ts", "vite.web.config.ts", "config/vitest.config.ts", @@ -34,7 +34,11 @@ "tests/**/*.{ts,tsx,mjs}", "**/*.test.{ts,tsx}" ], - "project": ["src/**/*.{ts,tsx}", "config/scripts/**/*.{mjs,cjs,js,ts}", "build-plugins/**/*.ts"], + "project": [ + "src/**/*.{ts,tsx}", + "config/scripts/**/*.{mjs,cjs,js,ts}", + "config/build-plugins/**/*.ts" + ], "ignore": ["mobile/**", "out/**", "dist/**", "node_modules/**", "resources/**"], "ignoreDependencies": ["electron", "@types/*"], "ignoreExportsUsedInFile": true, diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 4abdba712a0..17cfd44cc18 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-03", + "updatedAt": "2026-08-04", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -185,6 +185,101 @@ ], "demotionRule": "Keep experimental or demote if any activity trigger shortens the rejection window, a transient burst reaches resolver retirement authority, sustained dead-session evidence no longer converges, shutdown permits late retirement, or the focused gate flakes without an identified product or harness fault." }, + { + "id": "ssh-filesystem.stream-inactivity-lifecycle", + "title": "SSH file streams bound inactivity without counting host sleep", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-ssh", + "layer": "ssh-file-stream-lifecycle", + "surfaces": ["SSH filesystem reads", "AI Vault remote scanning", "system sleep/wake"], + "platforms": ["macos", "linux", "windows"], + "providers": ["ssh2", "system-ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["ssh2", "system-ssh"], + "coverageNotes": "Deterministic fake-clock coverage proves inactivity cancellation, active-transfer renewal, sticky suspend replay before metadata, failure-isolated lifecycle fanout, committed-quit bridge disposal, timer cleanup, and listener cleanup. A real Docker SSH relay previously proved active and stalled transfer behavior; physical sleep/wake and Windows/Linux clients remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11362", + "https://github.com/stablyai/orca/pull/11364" + ], + "invariant": "A non-empty SSH file stream that produces no valid frame for 30 seconds must cancel at its authoritative stream reader and release all local lifecycle state. System suspend is sticky across metadata and subscription races, resume grants every still-live stream one fresh inactivity window, one failing consumer cannot block the others, and the sole Electron bridge survives a vetoed before-quit without retaining a per-stream Electron listener.", + "oracle": "Publish suspend before stream metadata resolves, jump wall time by one hour, and require the late-subscribing stream to remain pending until resume grants a fresh 30-second window. Deliver a valid final chunk and end frame, require exact content, then publish another resume and require zero timers, multiplexer listeners, or renewed work; separately require stalled cancellation at 30 seconds, atomic state replay, failure-isolated fanout, and bridge disposal only after the committed will-quit gate.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts src/main/startup/desktop-startup-ordering.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/providers/ssh-filesystem-provider-stream.test.ts", + "src/main/system-resume-broadcast.test.ts", + "src/main/system-power-lifecycle.test.ts", + "src/main/startup/desktop-startup-ordering.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/providers/ssh-filesystem-provider-stream.test.ts", + "assertions": [ + "cancels and cleans up a stream that stalls after metadata", + "keeps a long stream alive while chunks continue arriving", + "grants an active stream a fresh inactivity window after system resume", + "keeps metadata received during suspend paused until resume" + ] + }, + { + "file": "src/main/system-resume-broadcast.test.ts", + "assertions": ["publishes suspend and resume to main-process lifecycle consumers"] + }, + { + "file": "src/main/system-power-lifecycle.test.ts", + "assertions": [ + "replays suspended state to a late subscriber", + "atomically replays a transition to a subscriber added during publication", + "isolates a failing listener from the remaining subscribers" + ] + }, + { + "file": "src/main/startup/desktop-startup-ordering.test.ts", + "assertions": [ + "keeps the power bridge through vetoable before-quit and disposes after commit" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts src/main/startup/desktop-startup-ordering.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 0.8, + "summary": "Four focused files and 33 tests passed, including stalled cancellation, active progress, late metadata replay, failure isolation, committed-quit bridge lifetime, and post-settlement cleanup." + } + ], + "runtimeBudget": { + "p95Seconds": 2, + "scope": "deterministic SSH file-stream and system power lifecycle unit tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic correction suite passes locally; focused CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical oracle is incomplete on origin/main because stalled streams never settle, and the pre-correction candidate cancels immediately after a simulated one-hour suspend. The corrected candidate bounds uninterrupted inactivity while granting a full post-resume window." + }, + "performanceBudget": { + "required": true, + "evidence": "One app-global powerMonitor listener publishes to an in-memory set. Each active non-empty read retains one set entry and one unref'd timer, clears both on settlement, and performs constant work per valid chunk; each power transition performs O(active streams) isolated notifications with no polling, provider scan, subprocess, renderer work, or per-stream Electron listener." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Capture physical macOS, Linux, and Windows sleep/wake evidence with a live SSH read.", + "Keep timeout, post-resume renewal, and zero-retained-lifecycle assertions green." + ], + "knownGaps": [ + "Suspend/resume is injected at the authoritative main-process event seam rather than by physically sleeping the runner.", + "Slow links that produce no complete valid frame for 30 uninterrupted awake seconds remain intentionally retryable failures." + ], + "demotionRule": "Keep experimental or demote if sleep consumes inactivity evidence, stalled streams become unbounded, or settled reads retain timers or lifecycle subscriptions." + }, { "id": "ssh-relay.staged-upload-recovery", "title": "SSH relay uploads remain retryable before the shared install lock", @@ -858,16 +953,17 @@ "providers": ["local", "daemon", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh"], - "coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, SSH identity transfer, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.", + "coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, SSH identity transfer, the promoted renderer's agent-resume accounting, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8457", "https://github.com/stablyai/orca/issues/9563" ], "invariant": "A safely promotable headless serve process is the single app owner. Desktop activation preserves its daemon-backed sessions. On macOS, a CLI-supervised serve update keeps the node-mode parent alive across ShipIt's atomic bundle swap, restarts with the original serve arguments only after the target bundle is present, and clears handoff state only after that target version reports runtime readiness. Unsupported or failed handoffs leave the current serving owner intact or recover it once without an install retry loop.", - "oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.", + "oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. A joined lock-owner/activation/hydration contract asserts that a forced relaunch opens exactly one window and that the renderer promoted inside the serve process launches zero agent resumes, creates no replacement tab or startup command, and leaves every surviving session record untouched. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/launch.test.ts src/main/serve-update-handoff.test.ts src/main/updater.headless-serve-install.test.ts src/main/updater.test.ts src/main/updater.mac-install.test.ts src/main/window/attach-main-window-services.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts", "pnpm exec electron-vite build --mode e2e", "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1" ], @@ -882,6 +978,7 @@ "src/cli/runtime-client.test.ts", "src/cli/runtime/websocket-transport.test.ts", "src/main/runtime/orca-runtime.test.ts", + "src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts", "tests/e2e/headless-serve-desktop-activation.spec.ts" ], "assertionRefs": [ @@ -963,6 +1060,15 @@ "desktop-only bell, command, and link scanners remain disabled until a real renderer graph is ready" ] }, + { + "file": "src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts", + "assertions": [ + "a forced desktop relaunch reaching the headless lock owner opens exactly one window, and only after the persistent provider settles", + "a duplicate `orca serve` launch never promotes the headless owner", + "the renderer promoted inside the serve process resumes zero agents while daemon panes survive, before and after those panes rebind their PTYs", + "no replacement resume tab, startup command, or automatic-resume claim is created, and every surviving session record is left untouched" + ] + }, { "file": "tests/e2e/headless-serve-desktop-activation.spec.ts", "assertions": [ @@ -973,6 +1079,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-05", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts", + "result": "passed", + "durationSeconds": 2, + "summary": "Six tests passed joining the serve lock owner, the activation gate, and the promoted renderer's resume accounting. Red evidence: reverting the hidden-pane ownership predicate launched two duplicate codex resume tabs; additionally zeroing the live-PTY check made both hydration passes red; removing the duplicate-serve argv guard opened a window for `--serve`; always marking the gate ready removed the fail-closed diagnostic; refusing to open a window dropped both promotion assertions." + }, { "date": "2026-07-21", "runner": "local", @@ -5462,6 +5577,7 @@ "direct SSH folder workspace reattach", "direct SSH split-pane retry ownership", "same-authority terminal correction", + "remote-runtime host surface materialization", "remote-runtime mirror polling", "remote-runtime network recovery", "terminal create idempotency", @@ -5480,9 +5596,10 @@ "https://github.com/stablyai/orca/pull/6955", "https://github.com/stablyai/orca/pull/6979", "https://github.com/stablyai/orca/pull/7009", - "https://github.com/stablyai/orca/pull/8597" + "https://github.com/stablyai/orca/pull/8597", + "https://github.com/stablyai/orca/issues/11541" ], - "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.", + "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle. Reconnect must alternate exact activation with authoritative inventory so neither a stale activation response nor an activation failure can strand or retire a pane, and activating a parked surface whose persisted binding was already retired must respawn it rather than report a changed owner after signalling its exit.", "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", @@ -5492,6 +5609,8 @@ "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/components/terminal-pane/pty-connection.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/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees.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 src/shared/ssh-types.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", + "pnpm run build:web-from-renderer", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" @@ -5519,6 +5638,7 @@ "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", + "tests/e2e/paired-remote-terminal-materialization-reconnect.spec.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", @@ -5946,6 +6066,120 @@ ], "demotionRule": "Cannot promote if provider failure can close panes or if the oracle is screenshot-only." }, + { + "id": "terminal-input.remote-write-rejection-recovery", + "title": "Rejected paired-runtime terminal input remounts the pane", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "paired-runtime-stream-contract", + "surfaces": [ + "terminal multiplex input", + "legacy binary terminal input", + "one-shot terminal.send fallback", + "pane recovery", + "pty:hasPty liveness routing" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["paired-runtime"], + "coverageNotes": "One end-to-end contract runs the real dispatcher, renderer multiplexer, remote transport, and pty-connection together and requires the tab remount, so the signal is proven past the transport callback it used to die behind. Focused contracts cover capability negotiation, legacy binary subscriptions, stream-id reuse, pane lifecycle reuse, the one-shot JSON fallback, and main refusing to answer liveness for a `remote:` id. Live headed/headless paired-runtime and mixed installed releases remain uncollected.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-2830", + "https://github.com/stablyai/orca/issues/11124" + ], + "invariant": "When a paired-runtime client accepts terminal input locally but the authoritative host rejects the PTY write, a capability-compatible stream must notify only that current pane generation and that notification must end in an actual tab remount — no local liveness probe may veto it, because main owns no registry entry for a `remote:` id and must answer unknown for one. A host must never send the new opcode to a legacy or un-negotiated client, and a late rejection must never recover a replacement stream or pane lifecycle.", + "oracle": "Wire the real dispatcher to the real renderer multiplexer, remote transport, and pty-connection over one bridged subscription; type into the pane, reject the authoritative runtime send before any process write, and require both the WriteUnavailable frame and a remountTerminalTabForRecovery call — repeated for every answer main can produce for a `remote:` id (fabricated dead, unknown, thrown). Separately: require one frame only for a capability-declaring client by driving an un-negotiated legacy binary subscriber first and a capable one second on the same runtime, so the capable frame proves the rejection had already been processed for both. Reuse the stream id before releasing a held rejection and require no signal; replace the stream or detach and reattach the same handle before releasing held failures and require no stale recovery. Require pty:hasPty to answer null for a `remote:` id without consulting the local provider.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts src/main/ipc/pty.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/runtime/rpc/terminal-multiplex.test.ts", + "src/renderer/src/runtime/runtime-terminal-stream.test.ts", + "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", + "tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts", + "src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts", + "src/main/ipc/pty.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/rpc/terminal-multiplex.test.ts", + "assertions": [ + "rejected authoritative input emits WriteUnavailable only for capable multiplex and legacy binary clients", + "an un-negotiated legacy binary subscriber never receives the rejection opcode", + "a late rejection cannot target a replacement stream with the same id" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", + "assertions": [ + "WriteUnavailable reaches the current pane recovery callback without a fatal error", + "superseded streams and same-handle pane lifecycles ignore delayed rejections", + "a rejected one-shot runtime fallback invokes pane recovery" + ] + }, + { + "file": "tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts", + "assertions": [ + "a host-rejected write travels dispatcher to multiplexer to transport to pty-connection and remounts the tab", + "no answer the local liveness probe can give for a `remote:` id blocks that remount" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts", + "assertions": [ + "input-rejected-by-host recovery consults no liveness probe", + "input-rejected-by-host still coalesces under the shared recovery cooldown" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "pty:hasPty answers unknown for a paired-runtime handle instead of the local provider's fabricated dead" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-05", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts src/main/ipc/pty.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 15, + "summary": "678 passed / 0 failed with the end-to-end remount contract, the un-negotiated legacy binary gate, and the pty:hasPty liveness-routing contract added." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "focused paired-runtime host and renderer contracts plus the end-to-end remount chain" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Deterministic controlled-promise tests pass locally; CI and soak history have not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "On origin/main e5f49e0e1d, the test-only dispatcher oracle subscribed, accepted client input, invoked the authoritative host send once, recorded no process write, and failed only because no WriteUnavailable frame returned. The end-to-end contract was then red at the last hop on the delivery-only implementation — the host frame arrived and no remount followed — for all three liveness answers (3 failed / 0 passed), and green after the recovery routing fix (3 passed). Deleting the legacy-binary capability gate makes terminal-multiplex red (1 failed / 62 passed); deleting the pty:hasPty `remote:` guard makes the main contract red with the fabricated `false`; reverting either half of the renderer routing makes the end-to-end contract red (3 failed). Every mutation was restored by re-applying the edit and re-verified green." + }, + "performanceBudget": { + "required": true, + "evidence": "The change adds one optional capability field, one constant-time outcome classification per existing write, and one rejection-only frame/callback. The recovery routing adds one string comparison per rejection and removes an IPC round-trip on that path; the pty:hasPty guard is a prefix test that short-circuits a provider lookup. It adds no polling, timer, provider listing, subprocess, retained payload, or cross-pane fanout." + }, + "promotionCriteria": [ + "Collect headed and headless paired-runtime journeys with a rejected host write.", + "Collect mixed installed-release evidence in both client/server directions.", + "Collect CI soak history with no unexplained flakes." + ], + "knownGaps": [ + "Live headed and headless paired-runtime journeys are not collected.", + "Linux, Windows, mobile, and mixed installed-release runs are not collected.", + "Dead-record connected-state correction remains tracked separately in STA-2896." + ], + "demotionRule": "Keep experimental or demote if rejected input can remain silent, the signal stops short of a remount, a legacy or un-negotiated client receives an unknown opcode, a liveness probe fabricates an answer for a `remote:` id, a stale failure recovers a replacement pane, or the deterministic contract flakes without an identified product or harness bug." + }, { "id": "terminal-provider.wsl-restore-contract", "title": "WSL terminals preserve launch identity, liveness, and restore boundaries", @@ -8080,21 +8314,24 @@ "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local"], - "coverageNotes": "A deterministic renderer contract models one visible manager plus 64 mounted hidden managers and hidden synchronized/TUI output. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels. Production v1.4.163 evidence linked the same 49-manager fanout to paired traffic, but the candidate has not been rerun against an isolated live paired server.", + "coverageNotes": "A deterministic renderer contract models one visible manager plus 64 mounted hidden managers, hidden synchronized/TUI output, and five minutes of sustained streaming recovery requests. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels. Production v1.4.163 evidence linked the same 49-manager fanout to paired traffic, but the candidate has not been rerun against an isolated live paired server.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7054", - "https://github.com/stablyai/orca/pull/7604" + "https://github.com/stablyai/orca/pull/7604", + "https://github.com/stablyai/orca/issues/12094" ], - "invariant": "Ordinary document visibility transitions and hidden terminal output must not clear the shared WebGL glyph atlas. Heavy reset-and-refresh recovery may touch only managers with visible terminal surfaces; hidden managers recover when revealed. Genuine OS resume remains a heavy recovery trigger.", - "oracle": "Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require exactly one reset and one refresh; parse hidden synchronized and high-confidence TUI output and require zero global atlas-recovery schedules. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.", + "invariant": "Ordinary document visibility transitions and hidden terminal output must not clear the shared WebGL glyph atlas. Heavy reset-and-refresh recovery may touch only managers with visible terminal surfaces; hidden managers recover when revealed. Sustained streaming recovery requests must keep resets rate-bounded without starving repair. Genuine OS resume remains a heavy recovery trigger.", + "oracle": "Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require exactly one reset and one refresh; parse hidden synchronized and high-confidence TUI output and require zero global atlas-recovery schedules. Across five minutes of 300ms streaming recovery requests, require more than one but at most 75 resets, no internal or trailing repair gap above 6.5 seconds, atlas-preserving presentation while a reset is suppressed, and one delayed wipe for a final suppressed settle. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" ], "testFiles": [ "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts", + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", "tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts" @@ -8110,6 +8347,13 @@ "preserves WebGL texture atlases when the active terminal document becomes visible" ] }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", + "assertions": [ + "sustained streaming requests keep atlas resets rate-bounded with no repair starvation", + "suppressed resets present live buffers, cancel repair during resumed output, receive a trailing repair after settle, and clean up pending timers" + ] + }, { "file": "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", "assertions": ["bounds atlas recovery to visible managers"] @@ -8130,6 +8374,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-04", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", + "result": "passed", + "durationSeconds": 0.5, + "summary": "Seven deterministic tests passed for sustained reset cadence, bounded repair gaps, suppressed presentation with trailing repair, clock rollback, one-shot bypasses, and pending-timer cleanup." + }, { "date": "2026-08-01", "runner": "local", @@ -8172,7 +8425,7 @@ }, "performanceBudget": { "required": true, - "evidence": "A recovery with one visible and 64 hidden managers performs one reset and one refresh instead of 65 of each. Hidden synchronized/TUI output schedules zero global recovery work; no polling, cooldown, provider call, or parking change is added." + "evidence": "A recovery with one visible and 64 hidden managers performs one reset and one refresh instead of 65 of each. Hidden synchronized/TUI output schedules zero global recovery work. Sustained 300ms recovery requests keep full atlas resets at or below 75 over five minutes while preserving a 6.5-second maximum repair gap. A final suppressed settle schedules one cancelable repair at the next budget boundary; resumed streaming cancels it. The rate budget adds no polling, recurring timer, provider call, or parking change." }, "promotionCriteria": [ "Accumulate stable macOS Electron runs with real BrowserWindow visibility transitions.", @@ -9418,6 +9671,88 @@ "The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology." ], "demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault." + }, + { + "id": "remote-wire.cross-version-terminal-journey", + "title": "A released client and a current server still complete one terminal journey in both skew directions", + "maturity": "experimental", + "protection": "partial", + "owner": "remote-runtime", + "layer": "cross-version-protocol-integration", + "surfaces": [ + "terminal binary stream framing", + "terminal multiplex subscribe handshake and capability negotiation", + "host-published snapshot and output projection", + "remote terminal reconnect" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["paired-runtime"], + "coverageNotes": "Loads the real host RPC methods, the real RpcDispatcher, and the real renderer terminal multiplexer from two builds (current working tree and the newest release tag) and drives them against each other over an in-process transport that reproduces production frame routing, including the host-side decode that silently drops unknown opcodes. Covers the terminal stream only; the session-tab sync channel, agent-session publications, file/Git RPCs, mobile E2EE framing, and the relay transport are uncovered.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/12641", + "https://github.com/stablyai/orca/pull/12655" + ], + "invariant": "A client and a server built from different releases must complete subscribe, input delivery to the process, hide/reveal buffer snapshot, transport drop, and resubscribe with no frame refused by the receiving build's decoder, the same negotiated capabilities, and the same published snapshot content — so a new optional field stays safe, a new opcode is only sent after negotiation, and a change in what the host publishes is visible before release.", + "oracle": "Run one fixed journey per pairing (old client/new server, new client/old server, and current/current as control) and assert the recorded step list, the exact named frame sequence, both subscribed events with their negotiated capabilities, the exact input texts the host wrote to the PTY before and after reconnect, the rendered snapshot and live-output content, and an empty set of decoder-rejected frames in either direction. Missing host runtime methods are reported by name so a harness gap can never be read as a wire incompatibility.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts" + ], + "testFiles": ["tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts"], + "assertionRefs": [ + { + "file": "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts", + "assertions": [ + "expect(record.completed).toEqual([...JOURNEY_STEPS])", + "expect(record.frameSequence).toEqual(EXPECTED_JOURNEY_FRAMES)", + "expect(record.rejected).toEqual([])", + "expect(record.inputAtProcess).toEqual([JOURNEY_INPUTS.first, JOURNEY_INPUTS.second])", + "expect(event.capabilities).toEqual({ outputPause: 1 })" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-05", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts", + "durationSeconds": 6, + "summary": "v1.4.169 against working tree c4d5a535f2; all three pairings produced the identical 16-frame journey with zero rejected frames." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "one baseline checkout extraction plus three in-process journeys" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "New gate; no soak history yet. The journey uses observed-state barriers only, with no sleeps or elapsed-time oracles." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Red proven separately for each rule by injecting the violation into the working tree and reverting it. Rule 2: adding opcode 17 and sending it ungated from the client turned new-client/old-server red with rejected rawOpcode 17 in the client-to-host direction, while old-client/new-server stayed green. Rule 3: making the host stop publishing the snapshot `source` field turned both new-server pairings red and left the old-server pairing green; trimming the published initial buffer removed the SnapshotChunk frame and failed the frame-sequence oracle. Rule 1: adding an optional `hiddenOutputReason` field to the snapshot frame kept all pairings green, and making the client require that field turned only new-client/old-server red." + }, + "performanceBudget": { + "required": false, + "evidence": "Test-only infrastructure; it adds no product code path. The extracted baseline tree is cached by resolved commit, so repeat runs skip extraction and each journey completes in roughly 35ms." + }, + "promotionCriteria": [ + "Extend the matrix beyond two version points, for example the previous two minor releases.", + "Cover a second wire surface, starting with the session-tab sync channel that PR #12641 changed.", + "Collect 100 consecutive CI passes on the dedicated cross-version-wire job.", + "Run the job on Linux and Windows runners, not only macOS locally." + ], + "knownGaps": [ + "Only the terminal stream is covered; session tabs, agent sessions, file/Git RPCs, mobile E2EE framing, and the relay transport are not.", + "Only two version points are compared, so a regression introduced and reverted between them is invisible.", + "The host runtime is a stub around a fake PTY, so real PTY, daemon, and SSH provider behavior is out of scope.", + "The baseline is the newest release tag by default, so the compared pair changes when a new release is cut unless ORCA_CROSS_VERSION_BASELINE_REF pins it.", + "tests/ is outside every tsconfig include, so the harness is linted and executed but not typechecked." + ], + "demotionRule": "Demote if the baseline checkout cannot be materialized in CI, if a pairing has to be skipped to keep the lane green, or if the journey stops asserting the full step list and frame sequence." } ] } diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index 21e9df0fc6c..11eae65d996 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -219,7 +219,7 @@ describe('computer-use e2e workflow', () => { 'config/scripts/daemon-boot-smoke.mjs', 'config/scripts/windows-daemon-workspace-close-repro.mjs', 'electron.vite.config.ts', - 'build-plugins/**', + 'config/build-plugins/**', 'src/main/daemon/**' ]) ) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 931f9b93d01..f59f4a80f88 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -10,7 +10,7 @@ import { BOOTSTRAP_FATAL_LOG_ENV_VAR, BOOTSTRAP_FATAL_LOG_FILE_NAME, createBootstrapFatalExitBanner -} from '../../build-plugins/bootstrap-fatal-exit-banner' +} from '../build-plugins/bootstrap-fatal-exit-banner' import { electronViteConfig } from '../../electron.vite.config' import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard' @@ -79,7 +79,7 @@ describe('Electron Vite output contract', () => { expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js') }) - it('externalizes packaged dependencies but bundles the daemon xterm graph', () => { + it('externalizes packaged dependencies but bundles self-contained main dependencies', () => { const external = electronViteConfig.main?.build?.rollupOptions?.external if (typeof external !== 'function') { throw new Error('Expected main-process external predicate') @@ -91,7 +91,9 @@ describe('Electron Vite output contract', () => { expect(external('node:fs', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) + expect(external('psl', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') }) diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index b9601a67d41..d65b29cd253 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' import { afterEach, describe, expect, it } from 'vitest' -import { createPlainNodeEntryGuardPlugin } from '../../build-plugins/plain-node-entry-guard' +import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard' let outputDir: string | undefined diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index c86ba764979..b27a1f945d9 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -1,5 +1,6 @@ import { build } from 'esbuild' import { spawn } from 'node:child_process' +import { randomBytes } from 'node:crypto' import { createRequire } from 'node:module' import { existsSync } from 'node:fs' import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' @@ -9,6 +10,12 @@ import { dirname, join, resolve } from 'node:path' const WAIT_TIMEOUT_MS = 20_000 const require = createRequire(import.meta.url) +// Why: endpoint credentials are 32–256 base64url chars; openClient requires an authenticated +// transport, and #12746 admits pty.data only after a consumer grant. +function writeEndpointCredential(path) { + return writeFile(path, randomBytes(32).toString('base64url'), 'utf8') +} + function withTimeout(promise, label, stderr) { return new Promise((resolvePromise, rejectPromise) => { const timer = setTimeout(() => { @@ -68,14 +75,59 @@ async function loadProtocol(bundleDir) { return require(outfile) } +function attachProcessStreams(proc) { + let stderr = '' + proc.stderr.on('data', (chunk) => { + stderr = `${stderr}${String(chunk)}`.slice(-8_000) + }) + return { + stderr: () => stderr + } +} + +function waitForStdoutSentinel(proc, protocol, stderr) { + let stdoutBuffer = Buffer.alloc(0) + return withTimeout( + new Promise((resolvePromise, rejectPromise) => { + let settled = false + const onData = (chunk) => { + stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) + const sentinel = Buffer.from(protocol.RELAY_SENTINEL) + const index = stdoutBuffer.indexOf(sentinel) + if (index < 0) { + return + } + settled = true + proc.stdout.off('data', onData) + proc.off('exit', onExit) + resolvePromise(stdoutBuffer.subarray(index + sentinel.length)) + } + const onExit = (code, signal) => { + if (settled) { + return + } + settled = true + proc.stdout.off('data', onData) + rejectPromise( + new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) + ) + } + proc.stdout.on('data', onData) + proc.once('exit', onExit) + }), + 'relay sentinel', + stderr + ) +} + function createRelayClient(entryPath, args, env, protocol) { const proc = spawn(process.execPath, [entryPath, ...args], { cwd: dirname(entryPath), env, stdio: ['pipe', 'pipe', 'pipe'] }) + const streams = attachProcessStreams(proc) const messages = [] - let stderr = '' let nextSequence = 1 let stdoutBuffer = Buffer.alloc(0) let ready = false @@ -88,9 +140,6 @@ function createRelayClient(entryPath, args, env, protocol) { messages.push(protocol.parseJsonRpcMessage(frame.payload)) } }) - proc.stderr.on('data', (chunk) => { - stderr = `${stderr}${String(chunk)}`.slice(-8_000) - }) proc.stdout.on('data', (chunk) => { if (ready) { decoder.feed(chunk) @@ -111,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil( - () => messages.slice(startIndex).find(predicate), - label, - () => stderr - ) + pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) const request = async (method, params = {}) => { const id = nextSequence++ @@ -141,7 +186,7 @@ function createRelayClient(entryPath, args, env, protocol) { proc, request, notify, - sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr), + sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', streams.stderr), messageCount: () => messages.length, waitForNotification: (startIndex, method, predicate = () => true) => waitForMessage( @@ -149,7 +194,20 @@ function createRelayClient(entryPath, args, env, protocol) { (message) => message.method === method && predicate(message.params ?? {}), `${method} notification` ), - stderr: () => stderr + stderr: streams.stderr + } +} + +async function stopProcess(proc, stderr, label) { + if (!proc || proc.exitCode !== null || proc.signalCode !== null) { + return + } + proc.kill('SIGTERM') + try { + await withTimeout(waitForExit(proc), `${label} shutdown`, stderr) + } catch { + proc.kill('SIGKILL') + await withTimeout(waitForExit(proc), `forced ${label} shutdown`, stderr) } } @@ -184,24 +242,64 @@ async function main() { } let tempRoot + let daemon + let daemonStreams let relay try { tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-')) const watchRoot = await realpath(tempRoot) const pidFile = join(tempRoot, 'watcher.pid') + const credentialFile = join(tempRoot, 'endpoint.credential') const protocol = await loadProtocol(tempRoot) const socketPath = process.platform === 'win32' ? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}` : join(tempRoot, 'relay.sock') + await writeEndpointCredential(credentialFile) + + // Why detached + --connect: the daemon primary stdio is unproved, so it cannot open a consumer + // session; only an endpoint-credential socket client is admitted for pty.data after #12746. + daemon = spawn( + process.execPath, + [ + relayEntry, + '--detached', + '--grace-time', + '0', + '--sock-path', + socketPath, + '--endpoint-dir', + join(tempRoot, 'agent-hooks'), + '--credential-file', + credentialFile + ], + { + cwd: dirname(relayEntry), + env: { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + daemonStreams = attachProcessStreams(daemon) + await waitForStdoutSentinel(daemon, protocol, daemonStreams.stderr) + relay = createRelayClient( relayEntry, - ['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')], - { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile }, + ['--connect', '--sock-path', socketPath, '--credential-file', credentialFile], + process.env, protocol ) await relay.sentinelReceived + // Why no outputFlowControl: legacy owner grant admits plain pty.data without delivery tokens. + const grant = await relay.request('pty.openClient', { + protocolVersion: 1, + clientInstanceId: `relay-watcher-fault-${process.pid}`, + requestedRole: 'session-owner' + }) + if (grant?.role !== 'session-owner') { + throw new Error(`expected session-owner grant, got ${JSON.stringify(grant)}`) + } + const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot }) const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}` let startIndex = relay.messageCount() @@ -213,7 +311,11 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr) + const firstWatcherPid = await waitForWatcherPid( + pidFile, + undefined, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` + ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() await writeFile(beforePath, 'before') @@ -224,7 +326,11 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr) + const replacementWatcherPid = await waitForWatcherPid( + pidFile, + firstWatcherPid, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` + ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) ? params.events.some( @@ -234,7 +340,7 @@ async function main() { ) const status = await relay.request('relay.status') - if (status.pid !== relay.proc.pid) { + if (status.pid !== daemon.pid) { throw new Error('relay.status did not come from the original surviving relay process') } const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}` @@ -257,7 +363,7 @@ async function main() { await relay.request('pty.shutdown', { id: spawned.id }) console.log( JSON.stringify({ - relayPid: relay.proc.pid, + relayPid: daemon.pid, killedWatcherPid: firstWatcherPid, replacementWatcherPid, faultSignal, @@ -268,15 +374,8 @@ async function main() { }) ) } finally { - if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) { - relay.proc.kill('SIGTERM') - try { - await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr) - } catch { - relay.proc.kill('SIGKILL') - await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr) - } - } + await stopProcess(relay?.proc, relay?.stderr ?? (() => ''), 'connect bridge') + await stopProcess(daemon, daemonStreams?.stderr ?? (() => ''), 'relay daemon') if (tempRoot) { await rm(tempRoot, { recursive: true, force: true }) } diff --git a/config/scripts/run-codex-real-account-validation.mjs b/config/scripts/run-codex-real-account-validation.mjs index 53d237f22ee..9318f412ffe 100644 --- a/config/scripts/run-codex-real-account-validation.mjs +++ b/config/scripts/run-codex-real-account-validation.mjs @@ -35,7 +35,6 @@ const RESTRICTED_ENV_KEYS = [ 'HOMEPATH', 'CODEX_HOME', 'ORCA_CODEX_HOME', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_E2E_HOME_DIR', 'ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA_PATH', @@ -69,7 +68,7 @@ async function resolveRealPath(candidate) { } } -export function createValidationEnv(inheritedEnv, layout, options = {}) { +export function createValidationEnv(inheritedEnv, layout) { const env = { ...inheritedEnv } for (const key of RESTRICTED_ENV_KEYS) { delete env[key] @@ -81,12 +80,7 @@ export function createValidationEnv(inheritedEnv, layout, options = {}) { NODE_ENV: 'development', ORCA_E2E_HOME_DIR: layout.homeDir, ORCA_E2E_USER_DATA_DIR: layout.userDataDir, - ORCA_USER_DATA_PATH: layout.userDataDir, - // Why: flag OFF pins every codex spawn to an explicit managed CODEX_HOME, - // so native codex never resolves the OS profile — the only Windows - // configuration where strict zero-event containment is reachable. It also - // exercises the emergency kill-switch lane users fall back to. - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: options.systemDefaultRealHome === 'off' ? '0' : '1' + ORCA_USER_DATA_PATH: layout.userDataDir } } @@ -268,8 +262,7 @@ function parseArgs(argv) { primaryHome: os.homedir(), configTemplate: null, tempParent: null, - laneAwareContainment: false, - systemDefaultRealHome: 'on' + laneAwareContainment: false } for (let index = 0; index < argv.length; index += 1) { const arg = argv[index] @@ -299,14 +292,8 @@ function parseArgs(argv) { options.skipBuild = true } else if (arg === '--keep') { options.keep = true - } else if (arg === '--system-default-real-home') { - const value = readValue() - if (value !== 'on' && value !== 'off') { - throw new Error('--system-default-real-home must be "on" or "off"') - } - options.systemDefaultRealHome = value } else if (arg === '--lane-aware-containment') { - // Why: on Windows the flag-ON system-default lane cannot be env-sandboxed + // Why: on Windows the system-default real-home lane cannot be env-sandboxed // (native codex ignores USERPROFILE), so strict zero-event containment is // structurally unreachable there. This mode records codex's designed // volatile churn without aborting while every other real-home write stays @@ -314,7 +301,7 @@ function parseArgs(argv) { options.laneAwareContainment = true } else if (arg === '--help') { console.log( - 'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template ] [--temp-parent ] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report ]' + 'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template ] [--temp-parent ] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--report ]' ) process.exit(0) } else { @@ -480,7 +467,7 @@ async function main() { const reportPath = options.reportPath ?? path.join(os.tmpdir(), `orca-codex-real-account-${options.scenario}-${Date.now()}.json`) - const launchEnv = createValidationEnv(process.env, layout, options) + const launchEnv = createValidationEnv(process.env, layout) let app = null let tripwire = null const abortController = new AbortController() diff --git a/config/scripts/run-codex-real-account-validation.test.ts b/config/scripts/run-codex-real-account-validation.test.ts index 0b10dee6ee8..43b94983749 100644 --- a/config/scripts/run-codex-real-account-validation.test.ts +++ b/config/scripts/run-codex-real-account-validation.test.ts @@ -46,29 +46,9 @@ describe('Codex real-account validation harness', () => { expect(env.CODEX_HOME).toBeUndefined() expect(env.ORCA_CODEX_HOME).toBeUndefined() expect(env.ZDOTDIR).toBeUndefined() - expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1') expect(env.SAFE_VALUE).toBe('preserved') }) - it('pins the real-home flag off when the system-default lane is disabled', async () => { - const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel') - const { layout, env } = runValidationModule<{ - layout: { tempRoot: string } - env: Record - }>( - ` - const { createValidationEnv, createValidationLayout } = await import(process.argv[1]) - const layout = await createValidationLayout({ primaryHome: process.argv[2] }) - const env = createValidationEnv({}, layout, { systemDefaultRealHome: 'off' }) - console.log(JSON.stringify({ layout, env })) - `, - [primaryHome] - ) - cleanupPaths.push(layout.tempRoot) - - expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('0') - }) - it('records only fingerprints for system-default and managed auth', async () => { const { layout, snapshot } = runValidationModule<{ layout: { tempRoot: string } diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs index baa061b3590..53cabd3380b 100644 --- a/config/scripts/run-idle-cpu-benchmark.mjs +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -483,7 +483,6 @@ async function main() { HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) } }) diff --git a/config/scripts/serve-headless-fresh-profile-pairing.mjs b/config/scripts/serve-headless-fresh-profile-pairing.mjs index 8865b841541..1cbfe92473a 100755 --- a/config/scripts/serve-headless-fresh-profile-pairing.mjs +++ b/config/scripts/serve-headless-fresh-profile-pairing.mjs @@ -55,7 +55,6 @@ Object.assign(childEnv, { ORCA_DEV_USER_DATA_PATH: profileDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' } : {}) diff --git a/config/scripts/verify-linux-wayland-gpu-sandbox.mjs b/config/scripts/verify-linux-wayland-gpu-sandbox.mjs index ef35b6f7f3d..a36f81db86b 100644 --- a/config/scripts/verify-linux-wayland-gpu-sandbox.mjs +++ b/config/scripts/verify-linux-wayland-gpu-sandbox.mjs @@ -219,7 +219,6 @@ async function runValidation(mode) { ORCA_DEV_USER_DATA_PATH: userDataPath, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ELECTRON_ENABLE_LOGGING: '1', ELECTRON_ENABLE_STACK_DUMPING: '1', ELECTRON_OZONE_PLATFORM_HINT: 'wayland', diff --git a/config/scripts/windows-apphang-repro/electron-dev-session.mjs b/config/scripts/windows-apphang-repro/electron-dev-session.mjs index 0aaead7237e..be09b97014b 100644 --- a/config/scripts/windows-apphang-repro/electron-dev-session.mjs +++ b/config/scripts/windows-apphang-repro/electron-dev-session.mjs @@ -63,7 +63,6 @@ export function launchDevApp({ cdpPort, userDataDir }) { ORCA_DEV_USER_DATA_PATH: userDataDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ORCA_SKIP_DEV_WEB_PREPARE: '1', ORCA_STARTUP_DIAGNOSTICS: '1', REMOTE_DEBUGGING_PORT: String(cdpPort), diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4247281300..3e0e59381a4 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -63,6 +63,7 @@ "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", "../src/main/grok/hook-service.ts", + "../src/main/grok/windows-grok-hook-script.ts", "../src/main/devin/hook-settings.ts", "../src/main/devin/hook-service.ts", "../src/main/devin/hook-config-json.ts", diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index e641dda1507..948a9e88a1f 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -2,7 +2,7 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "include": [ "../electron.vite.config.*", - "../build-plugins/**/*", + "./build-plugins/**/*", "../src/main/**/*", "../src/renderer/src/lib/skill-freshness-display-status.ts", "../src/preload/**/*", diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index d42021315f0..7c4bb7c359e 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -14,7 +14,12 @@ "../src/main/ipc/worktree-path-comparison.ts", "../src/main/wsl-distro-list-output.ts", "../src/main/wsl-distro-retry.ts", - "../src/main/wsl.ts" + "../src/main/wsl.ts", + "../src/main/startup/serve-desktop-activation.ts", + "../src/main/startup/single-instance-lock.ts", + "../src/main/startup/startup-diagnostics.ts", + "../src/main/window/focus-existing-window.ts", + "../src/main/window/macos-app-activation.ts" ], "compilerOptions": { "paths": { diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 98410899a3d..872ae567b24 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 14m + + downloads: 15m @@ -15,7 +15,7 @@ downloads downloads - 14m - 14m + 15m + 15m diff --git a/docs/mobile-relay-ux-findings.md b/docs/mobile-relay-ux-findings.md new file mode 100644 index 00000000000..3fbbd1419be --- /dev/null +++ b/docs/mobile-relay-ux-findings.md @@ -0,0 +1,442 @@ +# Mobile Relay UX — Investigation Findings & Fix Plan + +Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over +the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes +desktop or relay-server code. All file references are in `mobile/` of this worktree. + +## 1. Symptom → root-cause summary + +| # | Symptom | Root cause (verified) | +|---|---------|----------------------| +| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` | +| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication | +| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible | + +## 2. Verified end-to-end causal chains + +### S1 — Resume dead-ends on the host index page + +1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'` + (`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card + inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift + under the thumb. +2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting + `/h/[hostId]/session/[worktreeId]`. +3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves + the push to the host stack's **index route with no `hostId` param**. This exact failure mode is + documented twice in-repo ("cold Expo deep links resolve to index" — + `src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the + root cause named by PR #12001. +4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`. + `HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns + `state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**. +5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and + every fetch no-ops on `!client || connState !== 'connected'` + (`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are + static toolbar UI, so the page looks "real" but dead. +6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by + navigation history. + +Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not +the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with* +a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than +it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration +(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`), +so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it. + +**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through +the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`. +Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation +(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4), +catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9). + +### S2 — grey blink when focusing a healthy relay host + +1. Every focus of the host screen fires `notifyForeground()` + (`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps). +2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)` + (`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe + (`src/transport/mobile-endpoint-lifecycle.ts:62-64`). +3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls + `RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`). +4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session** + (`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns + unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink. +5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes + `'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot + (`src/components/StatusDot.tsx:11`), worktree queries blocked. +6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state + **stays 'disconnected'**: `migrateTo` only binds the new session's state after + `waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the + dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`) + fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial + delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`). +7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun. + +Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live +client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`) +— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host. + +Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the +network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The +asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a +connected direct client runs an activity probe that detects a half-open socket in ≤8s +(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground` +(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is +suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls +`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink +(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`). + +Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber, +`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a +missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey +during the async client acquire (Keychain read) that home never shows. + +### S3 — silent 5–10s relay-forced pairing + +Pairing phase: + +1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing` + (`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`). +2. The coordinator threads it **only to the direct candidate** + (`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate + (`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all + (`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution, + journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`. +3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its + 12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE + handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write + (`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for + where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it. +4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director + resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped + at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of + silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the + director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay + request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`). +5. Pairing logs also never reach `connectionLogStore` (single producer: + `src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a + pairing that just failed. + +Post-pairing phase: + +6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/` + (`app/pair-confirm.tsx:118-123`). +7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the + Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first** + (`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s` + (`src/transport/rpc-client.ts:126`) on a black-holed LAN. +8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live + progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor + start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`). +9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism + as S2) → green. Worst case with a director resolution failure and grace-credential retry: + ~29–58s under the old session's labels. +10. The "Orca Relay" path label only renders once `state === 'connected'` + (`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only + after the wait ends, and `classifyConnection` has no relay-aware branch + (`src/transport/connection-health.ts:45-100`). + +## 3. Anti-pattern sweep + +### (a) Uncoordinated deep pushes into `/h` from outside the host stack + +Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and +Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the +root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks +proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`). + +Bare pushes remaining (host stack plausibly cold at each): + +| Call site | Target | Cold scenario | +|---|---|---| +| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h//session/` or `/h/` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener | +| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 | +| `app/index.tsx:835` (Account-usage card) | `/h//accounts` | Home is the root route | +| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes | +| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first | + +Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination. +No ``, `navigationRef`, or `router.navigate` anywhere in `mobile/`. + +Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit +host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight +(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts +(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount +can be destroyed by the gate cycle. + +### (b) Surfaces that render grey 'disconnected' during expected transients + +Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing +entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound +fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's +`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also +refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved +locally what every other surface gets wrong. + +The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass) +**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold +start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical +client is not the cause — it already reports `'connecting'` synchronously by the time `connect()` +returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the +async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before +amber**. + +Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via +`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled +(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for +`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner +(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty +state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all, +`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat +composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history / +diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log +screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick +Action gates all read `=== 'connected'`; voice settings goes fully inert +(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts +screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`). + +Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on +focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` — +correct per billing, but state stays grey through the entire foreground re-dial rather than +flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the +pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths +(`:107,114,135`). + +Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**: +`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect; +`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets; +`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent +lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` → +`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch +(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is +wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read +only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s +entry means an empty remount. + +Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to +"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863` +and `tasks.tsx:10970`). + +### (c) Invisible relay establishment phases + +- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay + session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the + supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`). +- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states + (`src/transport/stable-logical-rpc-client.ts:182-223,267-299`). +- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`, + `mobile-relay-direct-upgrade-controller.ts:19`). +- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`. +- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`); + `classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch. +- Regression suite for connect-label stalls exists for the direct path only + (`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent. + +## 4. Fix plan + +Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire +protocol, so every old/new phone × old/new desktop pairing keeps working unless noted. + +### F0 (S1, quick win) — land PR #12001 ✅ MERGED + +Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check +against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and +auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR +branch. The PR routes Resume through the shared mount-then-replace mechanism +(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the +bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the +extracted module. +Backward compat: navigation-only, none. +Residuals tracked as F4/F7/F8/F9. + +### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch) + +Approach: split the nudge reasons that today all funnel into `setForeground(true)`: + +- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either + no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short + budget) and only enter recovery on failure — mirroring the direct path's activity probe. +- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement** + instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path + (proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the + replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying + green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is + still `'connected'`, so the fallback suspend is required for convergence). + +Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`), +`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend), +`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged +`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter. +Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing +`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already +accepts this). Half-open regression risk is covered by the fallback suspend. +Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot +stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the +background-suspend test unchanged. + +### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch) + +Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or +pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every +host screen with home's `?? 'connecting'`. Two companion changes in the same class: +- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving + the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button + currently drives the UI grey before amber. +- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the + cold-start gap (`client-context.tsx:87-153`) is amber too. + +**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to +`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/ +`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without +extending that predicate would silently disable the stale-list fallback and blank the list — +extend it to every not-connected state (or key it on "no live fetch has succeeded this mount"). +Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`, +`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate), +`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead +of `null`). +Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates — +acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that +key on `'disconnected'` specifically. +Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence; +host screen test for the stale-list fallback under `'connecting'`. + +### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch) + +Approach: add an optional `onLog` to `connectMobileRelayForPairing`, +`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread +`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay: +resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated", +"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore` +under the resolved host id so the Connection Log screen has a record post-pairing. +Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`, +`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`. +Risk: none (additive, phone-local). Old desktops: unaffected — logging only. +Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`; +extend `pairing-relay-candidate.test.ts` for per-attempt lines. + +### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes + +Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`) +and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts` +once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted +follow-up). `orca://` deep links can follow later via a route-level guard. +Risk: notification cold-start ordering (push before root nav ready) — the mechanism already +tolerates that by waiting for state commits. +Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target. + +### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo` + +Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the +dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on +success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a +still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI: +show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let +`MobileHostCard`/`classifyConnection` render it while not yet connected. +Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`, +`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`. +Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the +forwarding strictly gated on suspended/disconnected. +Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases: +forwarded states during suspended dial, no forwarding during live-session replacement, failure +restores `'disconnected'`. + +### F6 (S3, deeper) — happy-eyeballs relay start post-pairing + +Approach: when a relay credential bundle exists and the direct dial has not authenticated within a +short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure; +first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to +the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay +sockets on healthy LANs. +Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a +phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so +the path is naturally guarded). +Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex +(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too +broadly. +Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a +relay socket; relay loser closed after direct wins. + +### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce + +Approach: (1) on Resume tap with the host connected, validate the target against the freshest +`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead. +(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s +(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`) +to the host index with a notice. (3) Use the validating reader in +`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse` +(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites. +Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss. +Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel +exemptions. + +### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift + +Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its +late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately +from the snapshot in a disabled state until the host connects. +Files: `app/index.tsx` footer. +Risk: none. +Tests: render test asserting footer order/height stability across `resumeWorktree` arrival. + +### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack + +Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending +spinner instead of replacing children, preserving in-flight nested navigation; keep the hard +replace only for the `blocked` verdict. +Files: `src/components/HostProtocolGate.tsx`. +Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an +overlay must still block interaction until resolved; verify child mount effects don't fire gated +RPCs pre-verdict before choosing overlay vs. current behavior. +Tests: gate test asserting no unmount across `statusPending` for an already-mounted host. + +### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips + +Approach: audit the §3(b) destructive-clear sites and make each preserve data across a +not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt +impact: git history blanking rows on the reconnect branch +(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo +cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on +error); the diff-review "ready-state preserved" branch that is dead code because it sits after the +early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe +(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration +(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount +(`app/h/[hostId]/index.tsx:129,330`). +Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts +rather than inventing new indicators. The in-repo reference pattern is +`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as +"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable` +preserves proven counts across a failed refresh, flagging only `staleCounts`. +Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger* +(suspend blips), so this is hardening, not the primary fix. + +## 5. Backward compatibility (old/new phone × old/new desktop) + +- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing + steps change. Old phones against any desktop are untouched (they don't have the code). +- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths + never activate; pairing keeps the existing `method_not_found` downgrade + (`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay + candidate is created). +- New phone + old desktop with relay: all paths use existing RPCs (`status.get`, + `pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses + the same resume-credential flow lease rotation already exercises against production desktops. +- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today. + +## 6. Constants appendix (verified) + +| Constant | Value | Where | +|---|---|---| +| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` | +| Direct handshake timeout | 5s | `rpc-client.ts:127` | +| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` | +| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` | +| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` | +| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` | +| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` | +| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` | +| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` | +| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` | +| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` | +| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` | +| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` | diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md new file mode 100644 index 00000000000..6ca32f49c48 --- /dev/null +++ b/docs/reference/remote-wire-compatibility.md @@ -0,0 +1,101 @@ +# Remote wire compatibility + +Orca's remote-server feature pairs a desktop client to a remote Orca runtime, and +users update the two independently. **Mixed versions are the normal state**, not an +edge case. This page is the contract for changing anything a paired client and host +exchange: the runtime RPC envelope, the terminal binary stream, and the content +either side publishes over them. + +`src/shared/protocol-version.ts` says when to bump `RUNTIME_PROTOCOL_VERSION`. This +page covers the changes that do _not_ bump it and are therefore easy to get wrong. + +## Rule 1 — a new optional JSON field on an existing frame is safe + +Every JSON payload is parsed with a decoder that ignores unknown keys (zod `.strip()` +on RPC params, `JSON.parse` on stream frames). An older peer that has never heard of +the field simply does not read it. + +Safe: + +```ts +// host adds a field; older clients ignore it +encodeTerminalStreamJson({ kind, cols, rows, hiddenOutputReason }) +``` + +**The field is safe only for as long as every reader treats it as optional.** The +moment a newer client _requires_ it, that client is broken against every host that +predates the field — which is the same defect as removing a field, just discovered +later. If new behavior depends on the field being present, that is Rule 2: negotiate +it, or make the reader fall back. + +## Rule 2 — a new stream opcode is NOT safe; negotiate it + +`decodeTerminalStreamFrame` returns `null` for an opcode it does not know, and +`runtime-rpc.ts` drops that frame without an error: + +```ts +const frame = decodeTerminalStreamFrame(bytes) +if (!frame) { + return // silently dropped — the sender never learns +} +``` + +So a new opcode sent to an older peer does not fail loudly. It vanishes, and the +feature behind it appears to hang. Input sent under a new opcode is swallowed. + +A new opcode must be announced in the subscribe handshake and sent only after the +peer confirms it. The existing pattern is `SetOutputPaused` (opcode 16): + +- the client advertises support in the `Subscribe` frame's `capabilities`; +- the host echoes `capabilities: { outputPause: 1 }` on the `subscribed` event; +- the client sends opcode 16 only after that echo (`stream.supportsOutputPause`); +- the host only acts on opcode 16 when it negotiated it (`stream.supportsOutputPause`). + +Reuse an existing opcode with a new optional payload field (Rule 1) whenever that +expresses the change; reach for a new opcode only when framing genuinely differs. + +Opcode numbers are permanent. See the `Ack = 13` and `ClaimViewport = 14` comments +in `src/shared/terminal-stream-protocol.ts` for why a shipped number cannot be +reused even if the feature behind it is removed. + +## Rule 3 — changing what the host publishes breaks old clients with no wire change + +The frame shape can be untouched and the skew still real, because clients react to +frame _content_. PR #12641 is the worked example: the host stopped synthesizing a +finished agent status, and clients running older code saw different content in an +identical frame. + +Treat these as wire changes even though nothing in the codec moves: + +- a field the host stops populating (an old client reading it now sees `undefined`); +- a value whose meaning, units, or nullability changes; +- content the host stops synthesizing, trims, or starts deriving from a new source; +- a frame the host stops sending, or starts sending, on an existing path. + +If old clients cannot interpret the new projection correctly, gate it behind a +runtime capability the same way Rule 2 gates an opcode. + +## Enforcement + +`tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real +host RPC methods and the real renderer multiplexer from two builds against each +other — current working tree against the newest release tag, in both skew +directions — over one scripted terminal journey (subscribe, input, hide/reveal +snapshot, drop, reconnect). + +Run it with: + +```bash +pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts +``` + +It fails when a frame is refused by the receiving build's decoder (Rule 2), when the +observed frame sequence changes (Rule 3), or when published snapshot content or +negotiated capabilities differ from the contract. Adding an optional field keeps it +green (Rule 1); making a client depend on that field turns the new-client/old-host +pairing red. + +The harness covers the terminal stream only. It does **not** cover the session-tab +sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or +the relay transport. A change on those paths still needs its own reasoning against +the three rules above. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a6e656ac9c6..90b908200c4 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -3,13 +3,14 @@ import { resolve } from 'node:path' import { defineConfig, type UserConfig } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' -import { createBootstrapFatalExitBanner } from './build-plugins/bootstrap-fatal-exit-banner' -import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard' +import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner' +import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard' import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ '@xterm/headless', '@xterm/addon-serialize', + 'psl', // Why: Windows NSIS deploys app.asar before external resources; bootstrap must // not race the later resources/node_modules copy. 'zod' diff --git a/mobile/app.json b/mobile/app.json index 53654335486..87b50446486 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.37", + "version": "0.0.41", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 10 + "versionCode": 11 }, "plugins": [ "expo-router", diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index d5cbd0e0f1b..1292b6e5f5f 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -8,7 +8,8 @@ import * as Linking from 'expo-linking' import { colors } from '../src/theme/mobile-theme' import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' -import { getNotificationNavigationPath } from '../src/notifications/notification-routing' +import { getNotificationNavigationTarget } from '../src/notifications/notification-routing' +import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route' import { loadHosts } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' @@ -34,6 +35,7 @@ Notifications.setNotificationHandler({ export default function RootLayout() { const router = useRouter() + const openNotificationRoute = useOpenNotificationRoute() const handledNotificationIdsRef = useRef>(new Set()) useEffect(() => { @@ -68,6 +70,7 @@ export default function RootLayout() { return () => sub.remove() }, [router]) + // ─── Notification tap routing ─── // Why: iOS delivers local notification taps through expo-notifications, // not Linking. Route both cold-start and warm-start responses to the host // and worktree that scheduled the notification. @@ -91,9 +94,9 @@ export default function RootLayout() { } } - async function getNavigationPath(data: unknown): Promise { + async function getNavigationTarget(data: unknown) { const hosts = await loadHosts().catch(() => null) - return getNotificationNavigationPath(data, { + return getNotificationNavigationTarget(data, { knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined }) } @@ -118,13 +121,13 @@ export default function RootLayout() { } } - const path = await getNavigationPath(response.notification.request.content.data) + const target = await getNavigationTarget(response.notification.request.content.data) clearLastNotificationResponse() if (disposed) { return } - if (path) { - router.push(path) + if (target) { + openNotificationRoute(target) } } @@ -140,7 +143,8 @@ export default function RootLayout() { disposed = true sub.remove() } - }, [router]) + }, [openNotificationRoute]) + // ─── End notification tap routing ─── // Why: hide the native splash only once the navigation Stack has been laid // out — this is the earliest moment the user will see actual app content. diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index b71f171ceb8..debd57a924f 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -55,6 +55,8 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' import { useHostProtocolGates } from '../../../src/components/HostProtocolGate' import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner' +import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner' +import { visibleHostRouteNotice } from '../../../src/host-route-notice' import { MobileSearchField } from '../../../src/components/MobileSearchField' import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder' import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache' @@ -117,9 +119,12 @@ export function HostScreen({ action: actionProp, onHideSidebar }: HostScreenProps = {}) { - const params = useLocalSearchParams<{ hostId: string; action?: string }>() + const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>() const hostId = hostIdProp ?? params.hostId const action = actionProp ?? params.action + const [dismissedNotice, setDismissedNotice] = useState(null) + const noticeParam = params.notice?.trim() + const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice) const router = useRouter() const pathname = usePathname() const insets = useSafeAreaInsets() @@ -461,7 +466,7 @@ export function HostScreen({ setWorktreesLoaded(true) // Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list. if (hostId) { - setCachedWorktrees(hostId, confirmed) + setCachedWorktrees(hostId, confirmed, { proven: true }) } // Drop the optimistic active override once the host reports it active, so later desktop changes win. setOptimisticActiveWorktreeId((pending) => @@ -512,7 +517,8 @@ export function HostScreen({ useFocusEffect( useCallback(() => { // Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff). - clientRef.current?.notifyForeground() + // 'focus' keeps a healthy relay green — probe, never suspend (S2 grey blink). + clientRef.current?.notifyForeground('focus') }, []) ) @@ -734,10 +740,9 @@ export function HostScreen({ ) const displayWorktrees = useMemo(() => { - const base = - connState === 'disconnected' || connState === 'reconnecting' || connState === 'auth-failed' - ? lastKnownWorktrees - : worktrees + // Why: live `worktrees` is authoritative only while connected; under the amber + // mount default, connecting/handshaking must keep the pre-reconnect list too. + const base = connState === 'connected' ? worktrees : lastKnownWorktrees if (sleptIds.size === 0 && optimisticActiveWorktreeId === null) { return base } @@ -1100,6 +1105,14 @@ export function HostScreen({ /> )} + {/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */} + {routeNotice && ( + setDismissedNotice(noticeParam ?? null)} + /> + )} + {/* Search bar */} {showSearch && ( diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index efb0b4962c4..755d6605501 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -198,6 +198,9 @@ import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-d import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership' import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers' import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name' +import { useMissingWorktreeBounce } from '../../../../src/session/use-missing-worktree-bounce' +import { hostRouteWithNotice } from '../../../../src/host-route-notice' +import { LAST_VISITED_WORKTREE_STORAGE_KEY } from '../../../../src/worktree/last-visited-worktree-repo' import { acceptSessionSnapshot, applyClosedTabTombstones, @@ -859,12 +862,19 @@ export default function SessionScreen() { const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) const forceReconnectHost = useForceReconnect() - const worktreeName = useLiveWorktreeName({ + const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({ client, connState, routeName: routeWorktreeName, worktreeId }) + // Why: a workspace deleted on the desktop leaves every RPC on this route failing forever. + useMissingWorktreeBounce({ + hostId, + worktreeId, + resolution: worktreeResolution, + bounce: (id) => router.replace(hostRouteWithNotice(id, 'worktree-missing')) + }) // Master-detail state: wide layouts dock a tapped panel beside the session; narrow keeps it null and pushes full-screen routes. const { isWideLayout } = useResponsiveLayout() const [activePanel, setActivePanel] = useState(null) @@ -2640,7 +2650,7 @@ export default function SessionScreen() { useEffect(() => { if (hostId && worktreeId) { void AsyncStorage.setItem( - 'orca:last-visited-worktree', + LAST_VISITED_WORKTREE_STORAGE_KEY, JSON.stringify({ hostId, worktreeId }) ) } @@ -2872,7 +2882,8 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, tabId: matchingTab.id, notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent: 'user' }).catch(() => {}) } } @@ -2912,7 +2923,8 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, tabId: tab.id, notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent: 'user' }).catch(() => {}) } return @@ -2936,7 +2948,8 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, tabId: tab.id, notifyClients: false, - navigation: 'caller' + navigation: 'caller', + intent: 'user' }).catch(() => {}) } if (tab.type === 'browser') { @@ -4187,7 +4200,10 @@ export default function SessionScreen() { tabId: activePendingTerminalTab.id, leafId: activePendingTerminalTab.leafId, notifyClients: false, - navigation: 'caller' + navigation: 'caller', + // Why: this only ever runs for the tab the user is looking at, so it is the + // tail of their tap — the gesture that materializes a parked pane. + intent: 'user' }) .then((response) => { if (!response.ok) { @@ -4970,6 +4986,7 @@ export default function SessionScreen() { { - if (stale || !raw) { + void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => { + if (stale) { return } - try { - setLastVisited(JSON.parse(raw)) - } catch {} + // Why the validating reader: this record becomes the Resume card's navigation target, + // so a malformed or older-shaped payload must read as no history, not a broken route. + setLastVisited(readLastVisitedWorktreeRecord(raw)) }) for (const entry of allClientsRef.current) { if (entry.client.getState() === 'connected') { @@ -482,28 +494,43 @@ export default function HomeScreen() { .join(',') ]) - // Why: prefer the worktree last opened on this device so Resume reflects mobile session history. - // Why: don't gate on 'connected' so the card doesn't flash empty for ~1s on cold-start; cached data holds until fresh RPC lands. - const resumeWorktree = useMemo(() => { - // Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load. - if (lastVisited && hostStates[lastVisited.hostId] === 'connected') { - const cached = getCachedWorktrees(lastVisited.hostId) as HomeWorktreeSummary[] | null - const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId) - if (match) { - return { hostId: lastVisited.hostId, worktree: match } + // Why: the card renders from cached/snapshot data the moment a candidate exists — see + // selectHomeResumeCard for why its slot must not wait for the host to connect. + const resumeCard = useMemo( + () => + selectHomeResumeCard({ + hosts: sortedHosts, + hostStates, + worktreeInfo, + lastVisited, + cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null + }), + [sortedHosts, hostStates, worktreeInfo, lastVisited] + ) + + // Why: the card is drawn from a snapshot that can name a workspace the desktop has since + // deleted. When the host has proven otherwise, open its workspace list rather than a session + // screen whose every RPC would fail. An unproven catalog is not evidence — that tap goes + // through and the session screen bounces once the host answers (F7). + const openResume = useCallback( + (card: HomeResumeCard) => { + if ( + isResumeTargetConfirmedMissing( + card, + getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null + ) + ) { + router.push(hostRouteWithNotice(card.hostId, 'worktree-missing')) + return } - } - for (const host of sortedHosts) { - if (hostStates[host.id] !== 'connected') { - continue - } - const info = worktreeInfo[host.id] - if (info?.lastActiveWorktree) { - return { hostId: host.id, worktree: info.lastActiveWorktree } - } - } - return null - }, [sortedHosts, hostStates, worktreeInfo, lastVisited]) + openMobileSession({ + hostId: card.hostId, + worktreeId: card.worktree.worktreeId, + name: card.worktree.displayName || card.worktree.repo + }) + }, + [openMobileSession, router] + ) // Why: only show Account usage for connected hosts; stale cached usage would imply live data. const accountsHosts = useMemo(() => { @@ -545,7 +572,7 @@ export default function HomeScreen() { disabled={!primaryConnectedHost} style={({ pressed }) => [ styles.taskHomeCard, - !primaryConnectedHost && styles.quickActionDisabled, + !primaryConnectedHost && styles.cardDisabled, pressed && styles.hostCardPressed ]} onPress={() => { @@ -732,51 +759,45 @@ export default function HomeScreen() { ListFooterComponent={ {/* ─── Resume card ─── */} - {resumeWorktree ? ( + {resumeCard ? ( <> Resume [styles.resumeCard, pressed && styles.hostCardPressed]} - onPress={() => - openMobileSession({ - hostId: resumeWorktree.hostId, - worktreeId: resumeWorktree.worktree.worktreeId, - name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo - }) - } + disabled={!resumeCard.actionable} + style={({ pressed }) => [ + styles.resumeCard, + !resumeCard.actionable && styles.cardDisabled, + pressed && styles.hostCardPressed + ]} + onPress={() => openResume(resumeCard)} > - {resumeWorktree.worktree.displayName} + {resumeCard.worktree.displayName} - {resumeWorktree.worktree.repo} + {resumeCard.worktree.repo} {' · '} - {resumeWorktree.worktree.branch} + {resumeCard.worktree.branch} - Tasks - {renderTaskHomeCard()} - ) : ( - <> - Tasks - {renderTaskHomeCard()} - - )} + ) : null} + Tasks + {renderTaskHomeCard()} {/* ─── Quick actions ─── */} Quick Actions @@ -794,7 +815,7 @@ export default function HomeScreen() { disabled={!primaryConnectedHost} style={({ pressed }) => [ styles.quickAction, - !primaryConnectedHost && styles.quickActionDisabled, + !primaryConnectedHost && styles.cardDisabled, pressed && styles.hostCardPressed ]} onPress={() => { @@ -831,7 +852,7 @@ export default function HomeScreen() { styles.accountsCard, pressed && styles.hostCardPressed ]} - onPress={() => router.push(`/h/${host.id}/accounts`)} + onPress={() => openMobileAccounts(host.id)} > {showHostName ? ( @@ -1221,7 +1242,7 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: 10 }, - quickActionDisabled: { + cardDisabled: { opacity: 0.45 }, quickActionIcon: { diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index 261293e9901..e6aa691c76e 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/client-context' +import { useAllHostClients } from '../src/transport/use-all-host-clients' import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx index 4a0f6d07c79..a09d964b388 100644 --- a/mobile/app/voice-settings.tsx +++ b/mobile/app/voice-settings.tsx @@ -14,7 +14,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/client-context' +import { useAllHostClients } from '../src/transport/use-all-host-clients' import type { RpcClient } from '../src/transport/rpc-client' import { BottomDrawer } from '../src/components/BottomDrawer' import { VoiceModelList } from '../src/components/VoiceModelList' diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts new file mode 100644 index 00000000000..f938a98772c --- /dev/null +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { mobileAccountsRouteTarget } from './mobile-accounts-route' +import { + hostStackHostRoute, + navigateToHostStackRoute, + type HostStackNavigationState +} from '../navigation/host-stack-navigation' + +const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8') + +function navigationHarness(initialState: HostStackNavigationState) { + const stateListeners = new Set<() => void>() + let state = initialState + const navigation = { + addListener: vi.fn((_event: 'state', listener: () => void) => { + stateListeners.add(listener) + return () => stateListeners.delete(listener) + }), + dispatch: vi.fn(), + getState: () => state + } + return { + navigation, + setState(nextState: HostStackNavigationState) { + state = nextState + for (const listener of stateListeners) { + listener() + } + } + } +} + +describe('mobile accounts route', () => { + it('keeps the host id raw for the navigator to encode', () => { + expect(mobileAccountsRouteTarget('host/one')).toEqual({ + name: '[hostId]/accounts', + params: { hostId: 'host/one' } + }) + }) + + it('mounts the host before replacing it with the accounts route', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const push = vi.fn() + + navigateToHostStackRoute( + harness.navigation, + { push, replace: vi.fn() }, + 'host/one', + mobileAccountsRouteTarget('host/one') + ) + + expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + + harness.setState({ + index: 1, + routes: [ + { name: 'index' }, + { + name: 'h', + state: { + key: '/h', + index: 0, + routes: [ + { + key: 'host-index', + name: '[hostId]/index', + params: { hostId: encodeURIComponent('host/one') } + } + ] + } + } + ] + }) + + expect(harness.navigation.dispatch).toHaveBeenCalledWith({ + type: 'REPLACE', + target: '/h', + source: 'host-index', + payload: mobileAccountsRouteTarget('host/one') + }) + }) + + it('opens the home account-usage card through the cold-navigator-safe transition', () => { + const start = homeSource.indexOf('{/* ─── Account usage ─── */}') + + // Assert the marker first: a renamed banner would otherwise slice garbage and report a + // missing call instead of the real cause. + expect(start).toBeGreaterThanOrEqual(0) + + const accountsSection = homeSource.slice(start) + expect(accountsSection).toContain('openMobileAccounts(host.id)') + expect(accountsSection).not.toContain('/accounts`') + }) +}) diff --git a/mobile/src/accounts/mobile-accounts-route.ts b/mobile/src/accounts/mobile-accounts-route.ts new file mode 100644 index 00000000000..1ab134dc255 --- /dev/null +++ b/mobile/src/accounts/mobile-accounts-route.ts @@ -0,0 +1,10 @@ +import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' + +/** Host id stays raw — the navigator owns the params, so pre-encoding one would + * reach the accounts screen still escaped. */ +export function mobileAccountsRouteTarget(hostId: string): HostStackRouteTarget { + return { + name: '[hostId]/accounts', + params: { hostId } + } +} diff --git a/mobile/src/accounts/use-open-mobile-accounts.ts b/mobile/src/accounts/use-open-mobile-accounts.ts new file mode 100644 index 00000000000..de6df04bdc3 --- /dev/null +++ b/mobile/src/accounts/use-open-mobile-accounts.ts @@ -0,0 +1,14 @@ +import { useCallback } from 'react' +import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' +import { mobileAccountsRouteTarget } from './mobile-accounts-route' + +export function useOpenMobileAccounts(): (hostId: string) => void { + const openHostStackRoute = useOpenHostStackRoute() + + return useCallback( + (hostId) => { + openHostStackRoute(hostId, mobileAccountsRouteTarget(hostId)) + }, + [openHostStackRoute] + ) +} diff --git a/mobile/src/cache/worktree-cache.test.ts b/mobile/src/cache/worktree-cache.test.ts index f167c6c547d..d8ed620ebe0 100644 --- a/mobile/src/cache/worktree-cache.test.ts +++ b/mobile/src/cache/worktree-cache.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache' +import { setCachedWorktrees, getCachedWorktrees, getProvenCachedWorktrees } from './worktree-cache' // Why: AC #8498 guarantees a reconnect refetch writes through the // same cache path the host detail screen seeds from, so a reconnect can't @@ -41,3 +41,51 @@ describe('worktree-cache write-through', () => { expect(getCachedWorktrees(hostId)).toEqual(reconnected) }) }) + +// Why (F7): home seeds this cache from a persisted cold-start snapshot as well as from a live +// worktree.ps, and only the latter can prove a workspace *absent* — the Resume tap redirects +// off that distinction, so a seeded entry must never look authoritative. +describe('worktree-cache provenance', () => { + it('withholds unmarked writes from the proven reader', () => { + const hostId = 'host-seeded' + const seeded = [{ worktreeId: 'a' }] + + setCachedWorktrees(hostId, seeded) + + expect(getCachedWorktrees(hostId)).toEqual(seeded) + expect(getProvenCachedWorktrees(hostId)).toBeNull() + }) + + it('exposes a host-listed catalog to the proven reader', () => { + const hostId = 'host-proven' + const listed = [{ worktreeId: 'a' }, { worktreeId: 'b' }] + + setCachedWorktrees(hostId, listed, { proven: true }) + + expect(getProvenCachedWorktrees(hostId)).toEqual(listed) + }) + + it('keeps a fresh proven catalog when an unproven seed lands after it', () => { + const hostId = 'host-kept' + const listed = [{ worktreeId: 'a' }, { worktreeId: 'b' }] + setCachedWorktrees(hostId, listed, { proven: true }) + + // A cold-start snapshot seed must neither truncate nor de-prove the host-listed rows. + setCachedWorktrees(hostId, [{ worktreeId: 'a' }]) + + expect(getProvenCachedWorktrees(hostId)).toEqual(listed) + }) + + it('lets an unproven seed replace another unproven entry', () => { + const hostId = 'host-reseeded' + setCachedWorktrees(hostId, [{ worktreeId: 'a' }]) + setCachedWorktrees(hostId, [{ worktreeId: 'b' }]) + + expect(getCachedWorktrees(hostId)).toEqual([{ worktreeId: 'b' }]) + expect(getProvenCachedWorktrees(hostId)).toBeNull() + }) + + it('reports nothing proven for a host it has never cached', () => { + expect(getProvenCachedWorktrees('host-never-seen')).toBeNull() + }) +}) diff --git a/mobile/src/cache/worktree-cache.ts b/mobile/src/cache/worktree-cache.ts index 0b05f78b260..03da01bd197 100644 --- a/mobile/src/cache/worktree-cache.ts +++ b/mobile/src/cache/worktree-cache.ts @@ -5,6 +5,9 @@ type CachedWorktrees = { worktrees: unknown[] at: number + // Whether the host itself listed these rows this session, as opposed to a cold-start seed + // rebuilt from a persisted snapshot. Only a proven list can prove a worktree *absent*. + proven: boolean } const cache = new Map() @@ -12,12 +15,23 @@ const cache = new Map() const MAX_AGE_MS = 30_000 const MAX_ENTRIES = 20 -export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void { +export function setCachedWorktrees( + hostId: string, + worktrees: unknown[], + options?: { proven?: boolean } +): void { + // Why: a cold-start snapshot seed landing after a live worktree.ps must not erase + // the proof — or truncate the host-listed rows — the resume check depends on. + if (options?.proven !== true && readFreshEntry(hostId)?.proven) { + return + } // Why: Map.set on an existing key does not move it to the end of iteration // order. Delete first so the re-inserted key becomes the newest entry, // giving us true LRU eviction when the cap is hit. cache.delete(hostId) - cache.set(hostId, { worktrees, at: Date.now() }) + // Default false: a caller that has not said where the rows came from must never be taken + // as grounds for redirecting the user away from a workspace. + cache.set(hostId, { worktrees, at: Date.now(), proven: options?.proven === true }) if (cache.size > MAX_ENTRIES) { const oldest = cache.keys().next().value if (oldest) { @@ -27,6 +41,17 @@ export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void { } export function getCachedWorktrees(hostId: string): unknown[] | null { + return readFreshEntry(hostId)?.worktrees ?? null +} + +/** The rows only when the host listed them itself — null whenever absence cannot be trusted, + * which is every unproven or expired entry. */ +export function getProvenCachedWorktrees(hostId: string): unknown[] | null { + const entry = readFreshEntry(hostId) + return entry?.proven ? entry.worktrees : null +} + +function readFreshEntry(hostId: string): CachedWorktrees | null { const entry = cache.get(hostId) if (!entry) { return null @@ -35,5 +60,5 @@ export function getCachedWorktrees(hostId: string): unknown[] | null { cache.delete(hostId) return null } - return entry.worktrees + return entry } diff --git a/mobile/src/components/HostProtocolGate.test.ts b/mobile/src/components/HostProtocolGate.test.ts index 91d3b858a6a..94cae6d9170 100644 --- a/mobile/src/components/HostProtocolGate.test.ts +++ b/mobile/src/components/HostProtocolGate.test.ts @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, useEffect } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' @@ -14,7 +14,10 @@ vi.mock('react-native', () => ({ Linking: { openURL: nativeTestState.openUrl }, Platform: nativeTestState.platform, Pressable: 'Pressable', - StyleSheet: { create: (styles: T) => styles }, + StyleSheet: { + create: (styles: T) => styles, + absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 } + }, Text: 'Text', View: 'View' })) @@ -41,11 +44,20 @@ function GateConsumer() { return createElement('GateStatus', null, hostCapabilities.join(',')) } +// Counts mounts so a test can prove the routes were never torn down, which presence alone can't. +const probeMounts = { count: 0 } +function MountProbe() { + useEffect(() => { + probeMounts.count += 1 + }, []) + return createElement('MountProbe') +} + function gateElement() { return createElement( HostProtocolGate, { hostId: 'host-1' }, - createElement('HostContent', null, createElement(GateConsumer)) + createElement('HostContent', null, createElement(GateConsumer), createElement(MountProbe)) ) } @@ -69,6 +81,7 @@ describe('HostProtocolGate', () => { globalThis.IS_REACT_ACT_ENVIRONMENT = true nativeTestState.openUrl.mockClear() nativeTestState.platform.OS = 'ios' + probeMounts.count = 0 }) afterEach(() => { @@ -157,9 +170,56 @@ describe('HostProtocolGate', () => { const output = renderedText(renderer) expect(output).toContain('Checking host compatibility') expect(output).not.toContain('HostContent') + expect(probeMounts.count).toBe(0) expect(client.sendRequest).toHaveBeenCalledOnce() }) + it('overlays the pending spinner instead of unmounting routes mounted while connecting', async () => { + hostClient.current = { client: null, state: 'connecting' } + renderer = await renderGate() + expect(renderedText(renderer)).toContain('HostContent') + expect(probeMounts.count).toBe(1) + + const client = { + sendRequest: vi.fn().mockReturnValue(new Promise(() => {})) + } as unknown as RpcClient + await act(async () => { + hostClient.current = { client, state: 'connected' } + renderer?.update(gateElement()) + await Promise.resolve() + }) + + const output = renderedText(renderer) + expect(output).toContain('HostContent') + expect(output).toContain('Checking host compatibility') + // Why: the cold-start remount this replaces is exactly what destroys in-flight deep navigation. + expect(probeMounts.count).toBe(1) + const overlay = renderer.root + .findAllByType('View') + .find((node) => node.props.accessibilityViewIsModal === true) + expect(overlay?.props.pointerEvents).toBe('auto') + }) + + it('still replaces mounted routes when the verdict comes back blocked', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + hostClient.current = { client: null, state: 'connecting' } + renderer = await renderGate() + expect(renderedText(renderer)).toContain('HostContent') + + await act(async () => { + hostClient.current = { + client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }), + state: 'connected' + } + renderer?.update(gateElement()) + await Promise.resolve() + }) + + const output = renderedText(renderer) + expect(output).toContain('Update Orca Mobile') + expect(output).not.toContain('HostContent') + }) + it('keeps an already-validated host route mounted while reconnect status is pending', async () => { const client = { sendRequest: vi @@ -183,7 +243,10 @@ describe('HostProtocolGate', () => { await Promise.resolve() }) - expect(renderedText(renderer)).toContain('HostContent') + const output = renderedText(renderer) + expect(output).toContain('HostContent') + // Why: the host already answered once, so a reconnect probe must not dim the UI it validated. + expect(output).not.toContain('Checking host compatibility') expect(client.sendRequest).toHaveBeenCalledTimes(2) }) diff --git a/mobile/src/components/HostProtocolGate.tsx b/mobile/src/components/HostProtocolGate.tsx index 565784e2539..4d9c0c019f1 100644 --- a/mobile/src/components/HostProtocolGate.tsx +++ b/mobile/src/components/HostProtocolGate.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useRef, type ReactNode } from 'react' +import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react' import { ActivityIndicator, StyleSheet, View } from 'react-native' import { useHostClient } from '../transport/client-context' import { useHostStatusGates, type HostStatusGates } from '../transport/host-status-gates' @@ -27,12 +27,31 @@ export function HostProtocolGate({ hostId, children }: Props) { const gates = useHostStatusGates({ hostId, client, connState: state }) const { compatVerdict, statusPending } = gates const resolvedHostIdRef = useRef(null) + const mountedHostIdRef = useRef(null) const hostKey = hostId ?? null - if (state === 'connected' && client && !statusPending) { - resolvedHostIdRef.current = hostKey - } - if (statusPending && resolvedHostIdRef.current !== hostKey) { - // Why: child routes may call newer RPCs on mount, so wait until compatibility is known. + const resolvedNow = state === 'connected' && client !== null && !statusPending + const blocked = compatVerdict.kind === 'blocked' + const pending = statusPending && resolvedHostIdRef.current !== hostKey + const holdBack = pending && mountedHostIdRef.current !== hostKey + + // Why: React can replay or discard a render, so the latches record committed + // outcomes only — a discarded children render must not count as mounted. + useEffect(() => { + if (resolvedNow) { + resolvedHostIdRef.current = hostKey + } + if (blocked) { + // Why: the block screen unmounts the routes, so a later pending window + // must not assume a live tree it can overlay. + mountedHostIdRef.current = null + } else if (!holdBack) { + mountedHostIdRef.current = hostKey + } + }) + + if (holdBack) { + // Why: nothing is mounted yet for this host, so hold the routes back entirely + // rather than letting them mount (and fire their connect RPCs) pre-verdict. return ( ) } - if (compatVerdict.kind === 'blocked') { + if (blocked) { return } // Why: the host sidebar needs the same status fields; sharing the result avoids a second status.get per route. - return {children} + return ( + + + + {children} + + {pending ? ( + // Why: once the stack is mounted, unmounting it for a pending status.get destroys + // in-flight nested navigation, so cover it instead. Mount effects underneath still + // run — they wait for connState 'connected' and every capability-dependent call + // re-probes status.get itself, so nothing newer than the baseline fires here. + + + + ) : null} + + + ) } const styles = StyleSheet.create({ @@ -55,5 +105,17 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bgBase + }, + // Stays mounted across the overlay toggling so the routes below keep their identity. + host: { + flex: 1 + }, + pendingOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgBase, + zIndex: 1000, + elevation: 1000 } }) diff --git a/mobile/src/components/HostRouteNoticeBanner.tsx b/mobile/src/components/HostRouteNoticeBanner.tsx new file mode 100644 index 00000000000..28b43e71a97 --- /dev/null +++ b/mobile/src/components/HostRouteNoticeBanner.tsx @@ -0,0 +1,43 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { X } from 'lucide-react-native' +import { colors, spacing } from '../theme/mobile-theme' + +// Informational, not an error: the host is healthy and the user's target simply went away, +// so this stays monochrome rather than borrowing the auth-failed red. +export function HostRouteNoticeBanner({ + message, + onDismiss +}: { + message: string + onDismiss: () => void +}) { + return ( + + {message} + + + + + ) +} + +const styles = StyleSheet.create({ + banner: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + backgroundColor: colors.bgPanel, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + text: { flex: 1, color: colors.textSecondary, fontSize: 13 }, + dismiss: { padding: spacing.xs } +}) diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx index a1889793982..6138e311127 100644 --- a/mobile/src/components/MobileHostCard.test.tsx +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -2,7 +2,8 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectionVerdict } from '../transport/connection-health' -import type { HostProfile } from '../transport/types' +import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' +import type { ConnectionState, HostProfile } from '../transport/types' import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo @@ -47,14 +48,21 @@ describe('MobileHostCard', () => { renderer = null }) - async function renderCard(worktreeInfo: HostWorktreeInfo | undefined): Promise { + async function renderCard( + worktreeInfo: HostWorktreeInfo | undefined, + overrides?: { + state?: ConnectionState + verdict?: ConnectionVerdict + path?: MobileConnectionPath + } + ): Promise { await act(async () => { renderer = create( createElement(MobileHostCard, { host, - state: 'connected', - verdict, - path: 'lan', + state: overrides?.state ?? 'connected', + verdict: overrides?.verdict ?? verdict, + path: overrides?.path ?? 'lan', worktreeInfo, onPress: () => {}, onLongPress: () => {} @@ -84,6 +92,47 @@ describe('MobileHostCard', () => { ) }) + it('names the relay while the dial is still in flight', async () => { + const lines = await renderCard(undefined, { + state: 'connecting', + verdict: { kind: 'normal', label: 'Connecting…' }, + path: 'relay' + }) + + expect(lines).toContain('Connecting…') + expect(lines).toContain(' · Orca Relay') + }) + + it('names the relay while a failed direct dial is still retrying', async () => { + const lines = await renderCard(undefined, { + state: 'reconnecting', + verdict: { kind: 'normal', label: 'Reconnecting…' }, + path: 'relay' + }) + + expect(lines).toContain(' · Orca Relay') + }) + + it('leaves an idle disconnected host unlabelled', async () => { + const lines = await renderCard(undefined, { + state: 'disconnected', + verdict: { kind: 'normal', label: 'Disconnected' }, + path: 'relay' + }) + + expect(lines).not.toContain(' · Orca Relay') + }) + + it('does not guess a direct path before the dial resolves', async () => { + const lines = await renderCard(undefined, { + state: 'connecting', + verdict: { kind: 'normal', label: 'Connecting…' }, + path: 'lan' + }) + + expect(lines).not.toContain(' · Direct · LAN') + }) + it('shows no worktree line before the first read lands', async () => { const lines = await renderCard(undefined) diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index 79a58bf760e..f81f936405a 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -21,6 +21,11 @@ export function MobileHostCard(props: { onLongPress: () => void }) { const connected = props.state === 'connected' + // Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the + // path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is + // named — 'lan' doubles as the unknown-path default, so it would be a guess before connect. + const dialingPath = + ['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay' const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) return ( @@ -44,7 +49,7 @@ export function MobileHostCard(props: { {verdictDisplayLabel(props.verdict)} - {connected ? ` · ${mobileConnectionPathLabel(props.path)}` : ''} + {connected || dialingPath ? ` · ${mobileConnectionPathLabel(props.path)}` : ''} {connected && worktreeSummary ? ( diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx new file mode 100644 index 00000000000..3fb76dd3122 --- /dev/null +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -0,0 +1,98 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockResolvedValue(undefined), + removeItem: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + FlatList: 'FlatList', + Image: 'Image', + Keyboard: { dismiss: vi.fn() }, + Linking: { openURL: vi.fn() }, + Modal: 'Modal', + ScrollView: 'ScrollView', + Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios }, + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Switch: 'Switch', + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) +// Every icon in the drawer tree renders as a host element named after itself. +vi.mock( + 'lucide-react-native', + () => + new Proxy( + {}, + { + get: (_target, name) => (typeof name === 'string' ? name : undefined), + has: () => true + } + ) +) +vi.mock('./BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' })) +vi.mock('./bottom-drawer-modal-host', () => ({ BottomDrawerModalHost: 'BottomDrawerModalHost' })) +vi.mock('./PickerListDrawer', () => ({ PickerListDrawer: 'PickerListDrawer' })) +vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) +vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' })) + +import { setCachedRepos } from '../cache/repo-cache' +import { NewWorktreeModal } from './NewWorktreeModal' + +const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }] + +function repoPickerNames(renderer: ReactTestRenderer | null): string[] { + const pickers = renderer?.root.findAll((node) => node.type === 'PickerListDrawer') ?? [] + const repoPicker = pickers.find((node) => node.props.title === 'Repository') + return ((repoPicker?.props.items ?? []) as { label: string }[]).map((item) => item.label) +} + +describe('NewWorktreeModal repo list', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setCachedRepos('host-1', repos) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => { + const sendRequest = vi.fn().mockImplementation((method: string) => { + if (method === 'repo.list') { + return Promise.reject(new Error('connection closed')) + } + return new Promise(() => {}) + }) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + renderer = create( + createElement(NewWorktreeModal, { + visible: true, + client, + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + }) + ) + await Promise.resolve() + }) + await act(async () => { + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenCalledWith('repo.list') + expect(repoPickerNames(renderer)).toEqual(['orca']) + }) +}) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 21a69ec7d53..3fca3261ecc 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -327,11 +327,9 @@ function NewWorktreeModalContent({ }) } }) - .catch(() => { - if (!stale) { - setRepos([]) - } - }) + // Why (F10): a dropped connection rejects this call — keep the last-good list (the content + // remounts with the new host's cache when the client changes) instead of emptying the picker. + .catch(() => undefined) .finally(() => { if (!stale) { setLoading(false) diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/file-tree.test.ts index bdd9e9225cb..a11690047b0 100644 --- a/mobile/src/files/file-tree.test.ts +++ b/mobile/src/files/file-tree.test.ts @@ -34,6 +34,18 @@ describe('file-tree', () => { ]) }) + it('orders numbered names naturally, matching the desktop explorer', () => { + const cache: DirectoryCache = { + '': { entries: [entry('100 - b.txt'), entry('9 - c.txt'), entry('99 - a.txt')] } + } + + expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([ + 'file:9 - c.txt', + 'file:99 - a.txt', + 'file:100 - b.txt' + ]) + }) + it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => { const cache: DirectoryCache = { '': { diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index a9283cfa457..71f26ca155b 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -1,5 +1,6 @@ // Pure tree projection for the mobile file explorer. Mobile mirrors desktop // browse semantics by flattening cached files.readDir results as folders open. +import { compareFileNames } from '../../../src/shared/file-name-sort' export type MobileDirEntry = { name: string @@ -118,7 +119,7 @@ function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 } - return a.name.localeCompare(b.name) + return compareFileNames(a.name, b.name) } export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean { diff --git a/mobile/src/host-route-notice.test.ts b/mobile/src/host-route-notice.test.ts new file mode 100644 index 00000000000..46ba00a8597 --- /dev/null +++ b/mobile/src/host-route-notice.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + HOST_ROUTE_NOTICES, + hostRouteNoticeMessage, + hostRouteWithNotice, + visibleHostRouteNotice +} from './host-route-notice' + +describe('hostRouteNoticeMessage', () => { + it('maps a known code to its banner text', () => { + expect(hostRouteNoticeMessage('worktree-missing')).toBe(HOST_ROUTE_NOTICES['worktree-missing']) + }) + + it('renders nothing for absent or unrecognized codes', () => { + expect(hostRouteNoticeMessage(undefined)).toBeNull() + expect(hostRouteNoticeMessage('')).toBeNull() + // A newer build's code must not leak the raw param into the UI. + expect(hostRouteNoticeMessage('some-future-code')).toBeNull() + }) + + // A plain lookup returns Object.prototype members, which would hand the banner a function. + it('renders nothing for prototype keys', () => { + expect(hostRouteNoticeMessage('toString')).toBeNull() + expect(hostRouteNoticeMessage('constructor')).toBeNull() + expect(hostRouteNoticeMessage('__proto__')).toBeNull() + }) +}) + +describe('hostRouteWithNotice', () => { + it('encodes the host id into the noticed route', () => { + expect(hostRouteWithNotice('host/one', 'worktree-missing')).toBe( + '/h/host%2Fone?notice=worktree-missing' + ) + }) +}) + +describe('visibleHostRouteNotice', () => { + const message = HOST_ROUTE_NOTICES['worktree-missing'] + + it('shows a notice the user has not dismissed', () => { + expect(visibleHostRouteNotice(false, 'worktree-missing', null)).toBe(message) + }) + + it('stays silent once that code is dismissed', () => { + expect(visibleHostRouteNotice(false, 'worktree-missing', 'worktree-missing')).toBeNull() + }) + + // Dismissal is keyed by code so a later, different bounce still gets to speak. + it('still shows a different code after one was dismissed', () => { + expect(visibleHostRouteNotice(false, 'worktree-missing', 'some-other-code')).toBe(message) + }) + + it('draws nothing in the embedded sidebar, which shares the route', () => { + expect(visibleHostRouteNotice(true, 'worktree-missing', null)).toBeNull() + }) +}) diff --git a/mobile/src/host-route-notice.ts b/mobile/src/host-route-notice.ts new file mode 100644 index 00000000000..2e6d952f1f9 --- /dev/null +++ b/mobile/src/host-route-notice.ts @@ -0,0 +1,38 @@ +// Why a route param rather than a toast: the screen that learns the bad news (the session) +// unmounts as it bounces, so the message has to travel with the navigation to survive. + +export const HOST_ROUTE_NOTICES = { + 'worktree-missing': 'That workspace no longer exists on this host.' +} as const + +export type HostRouteNotice = keyof typeof HOST_ROUTE_NOTICES + +/** The banner text for a route param, or null when absent/unrecognized — an unknown code + * from a future build must render nothing rather than leak the raw param. */ +export function hostRouteNoticeMessage(notice: string | undefined): string | null { + // Why hasOwn: the param is attacker-adjacent URL text, and a plain lookup of 'toString' + // would hand the banner a function off the prototype instead of missing. + if (!notice || !Object.hasOwn(HOST_ROUTE_NOTICES, notice)) { + return null + } + return HOST_ROUTE_NOTICES[notice as HostRouteNotice] +} + +export function hostRouteWithNotice(hostId: string, notice: HostRouteNotice): string { + return `/h/${encodeURIComponent(hostId)}?notice=${notice}` +} + +/** The banner the host screen should draw, if any. + * `embedded` is the tablet sidebar, which shares the route with the routed screen — one + * bounce must not draw two banners. `dismissed` is keyed by code rather than a boolean so + * closing one notice cannot swallow a later, different one. */ +export function visibleHostRouteNotice( + embedded: boolean, + notice: string | undefined, + dismissed: string | null +): string | null { + if (embedded || (notice && notice === dismissed)) { + return null + } + return hostRouteNoticeMessage(notice) +} diff --git a/mobile/src/navigation/host-stack-navigation.test.ts b/mobile/src/navigation/host-stack-navigation.test.ts index 2dd3cc04e24..43848f35449 100644 --- a/mobile/src/navigation/host-stack-navigation.test.ts +++ b/mobile/src/navigation/host-stack-navigation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { coordinateHostStackNavigation, hostStackHostRoute, + hostStackRouteHref, navigateToHostStackRoute, type HostStackNavigationState } from './host-stack-navigation' @@ -14,7 +15,7 @@ const OTHER_TARGET = { // Removal is modeled for real: a no-op unsubscribe would let `setState` keep // calling a canceled listener, testing the `active` guard instead of teardown. -function navigationHarness(initialState: HostStackNavigationState) { +function navigationHarness(initialState: HostStackNavigationState | undefined) { const stateListeners = new Set<() => void>() let state = initialState const unsubscribe = vi.fn() @@ -33,7 +34,7 @@ function navigationHarness(initialState: HostStackNavigationState) { navigation, unsubscribe, listenerCount: () => stateListeners.size, - setState(nextState: HostStackNavigationState) { + setState(nextState: HostStackNavigationState | undefined) { state = nextState for (const listener of stateListeners) { listener() @@ -58,12 +59,18 @@ function committedHostState(hostIdParam: string): HostStackNavigationState { } } +// The shape app/_layout.tsx sees: Expo Router mounts it as a screen of its own internal +// navigator, so every route the host stack lives in sits one level below. +function rootLayoutScopedState(inner: HostStackNavigationState): HostStackNavigationState { + return { key: 'internal', index: 0, routes: [{ key: '__root', name: '__root', state: inner }] } +} + describe('host stack navigation', () => { it('matches a host committed as the encoded segment it was pushed as', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', TARGET) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -78,10 +85,85 @@ describe('host stack navigation', () => { }) }) + it('replaces the host stack seen from the root layout, one navigator further down', () => { + const harness = navigationHarness( + rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] }) + ) + + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) + harness.setState(rootLayoutScopedState(committedHostState('host/one'))) + + expect(harness.navigation.dispatch).toHaveBeenCalledWith({ + type: 'REPLACE', + target: '/h', + source: 'host-index', + payload: TARGET + }) + }) + + it('survives the state emitted before the root navigator has hydrated', () => { + const harness = navigationHarness(undefined) + + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) + + expect(() => harness.setState(undefined)).not.toThrow() + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + + harness.setState(rootLayoutScopedState(committedHostState('host/one'))) + expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1) + }) + + it('replaces through Expo Router when root state omits the mounted child stack', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() + + navigateToHostStackRoute(harness.navigation, { push: vi.fn(), replace }, 'host/one', TARGET) + harness.setState({ + index: 0, + routes: [{ name: 'h', params: { hostId: 'host/one' } }] + }) + + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).toHaveBeenCalledWith(hostStackRouteHref(TARGET)) + expect(harness.listenerCount()).toBe(0) + }) + + it('abandons the transition when navigation leaves the host route it was waiting on', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) + harness.setState({ index: 0, routes: [{ name: 'h' }] }) + harness.setState({ index: 0, routes: [{ name: 'index' }] }) + harness.setState(committedHostState('host/one')) + + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(harness.listenerCount()).toBe(0) + }) + it('ignores a different host whose id merely decodes badly', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(committedHostState('100%')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -92,7 +174,7 @@ describe('host stack navigation', () => { const controller = navigateToHostStackRoute( harness.navigation, - { push: vi.fn() }, + { push: vi.fn(), replace: vi.fn() }, 'host/one', TARGET ) @@ -110,7 +192,7 @@ describe('host stack navigation', () => { it('retargets a still-pending transition to the same host instead of pushing twice', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - const router = { push } + const router = { push, replace: vi.fn() } const pending = coordinateHostStackNavigation( null, diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts index 4fcdff8cac5..cb3b87cd98f 100644 --- a/mobile/src/navigation/host-stack-navigation.ts +++ b/mobile/src/navigation/host-stack-navigation.ts @@ -28,13 +28,21 @@ export type HostStackReplaceAction = Readonly<{ export type HostStackRootNavigation = { addListener: (event: 'state', listener: () => void) => () => void dispatch: (action: HostStackReplaceAction) => void - getState: () => HostStackNavigationState + // Why: the root layout's navigator has no committed state until it hydrates, and a + // notification tap can arm the transition before that first commit. + getState: () => HostStackNavigationState | undefined } export type HostStackHostRoute = `/h/${string}` +export type HostStackRouteHref = Readonly<{ + pathname: `/h/${string}` + params: Readonly> +}> + export type HostStackRouter = { push: (route: HostStackHostRoute) => void + replace: (route: HostStackRouteHref) => void } export type HostStackNavigationController = Readonly<{ @@ -52,6 +60,10 @@ export function hostStackHostRoute(hostId: string): HostStackHostRoute { return `/h/${encodeURIComponent(hostId)}` } +export function hostStackRouteHref(target: HostStackRouteTarget): HostStackRouteHref { + return { pathname: `/h/${target.name}`, params: target.params } +} + // Why: the host is pushed as an encoded segment, so the committed route may hold // either form — an id with `/`, `#`, or `%` must still match its own push. function hostParamMatches(param: unknown, expectedHostId: string): boolean { @@ -68,15 +80,31 @@ function hostParamMatches(param: unknown, expectedHostId: string): boolean { } } +/** The focused `h` route, however deep the caller's navigator sits above it: a screen + * inside the root stack sees it at the top, but app/_layout.tsx is itself a screen of + * Expo Router's internal navigator, so from there the root stack is one level down. */ +function focusedHostRoute(state: HostStackNavigationState): HostStackNavigationRoute | null { + let current: HostStackNavigationState | undefined = state + while (current) { + const route: HostStackNavigationRoute | undefined = current.routes[current.index] + if (!route) { + return null + } + if (route.name === 'h') { + return route + } + current = route.state + } + return null +} + function mountedHostStack( - state: HostStackNavigationState, + hostContainer: HostStackNavigationRoute, expectedHostId: string ): { key: string; routeKey: string } | null { - const hostContainer = state.routes[state.index] - const hostState = hostContainer?.state + const hostState = hostContainer.state const hostRoute = hostState?.routes[hostState.index] if ( - hostContainer?.name !== 'h' || !hostState?.key || hostRoute?.name !== '[hostId]/index' || !hostRoute.key || @@ -114,15 +142,22 @@ export function navigateToHostStackRoute( return } const state = navigation.getState() - const currentRoute = state.routes[state.index] - if (currentRoute?.name === 'h') { + if (!state) { + return + } + const hostContainer = focusedHostRoute(state) + if (hostContainer) { hostRouteSeen = true } else if (hostRouteSeen) { dispose() return } - const hostStack = mountedHostStack(state, hostId) + const hostStack = hostContainer && mountedHostStack(hostContainer, hostId) if (!hostStack) { + if (hostContainer && hostParamMatches(hostContainer.params?.hostId, hostId)) { + dispose() + router.replace(hostStackRouteHref(selectedTarget)) + } return } dispose() diff --git a/mobile/src/navigation/use-open-host-stack-route.ts b/mobile/src/navigation/use-open-host-stack-route.ts index 674ec5c34f7..7347d01f290 100644 --- a/mobile/src/navigation/use-open-host-stack-route.ts +++ b/mobile/src/navigation/use-open-host-stack-route.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useNavigation, useRouter } from 'expo-router' import { coordinateHostStackNavigation, @@ -14,11 +14,17 @@ let pendingNavigation: PendingHostStackNavigation | null = null export function useOpenHostStackRoute(): (hostId: string, target: HostStackRouteTarget) => void { const navigation = useNavigation() const router = useRouter() + // Why: an unmounting screen may only cancel a transition it armed itself — home + // unmounting for onboarding must not kill a notification push still in flight. + const armedRef = useRef(null) useEffect( () => () => { - pendingNavigation?.controller.cancel() - pendingNavigation = null + if (armedRef.current && armedRef.current === pendingNavigation) { + pendingNavigation.controller.cancel() + pendingNavigation = null + } + armedRef.current = null }, [] ) @@ -32,6 +38,7 @@ export function useOpenHostStackRoute(): (hostId: string, target: HostStackRoute hostId, target ) + armedRef.current = pendingNavigation }, [navigation, router] ) diff --git a/mobile/src/notifications/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts new file mode 100644 index 00000000000..eab13fdc046 --- /dev/null +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -0,0 +1,117 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { getNotificationNavigationTarget } from './notification-routing' +import { + hostStackHostRoute, + navigateToHostStackRoute, + type HostStackNavigationState +} from '../navigation/host-stack-navigation' + +const rootLayoutSource = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8') + +function navigationHarness(initialState: HostStackNavigationState | undefined) { + const stateListeners = new Set<() => void>() + let state = initialState + const navigation = { + addListener: vi.fn((_event: 'state', listener: () => void) => { + stateListeners.add(listener) + return () => stateListeners.delete(listener) + }), + dispatch: vi.fn(), + getState: () => state + } + return { + navigation, + setState(nextState: HostStackNavigationState | undefined) { + state = nextState + for (const listener of stateListeners) { + listener() + } + } + } +} + +// A notification tap is handled by app/_layout.tsx, which Expo Router mounts as a screen of its +// own internal navigator — hence the extra `__root` level around the app's root stack. +function rootLayoutScopedState(inner: HostStackNavigationState): HostStackNavigationState { + return { key: 'internal', index: 0, routes: [{ key: '__root', name: '__root', state: inner }] } +} + +describe('notification route coordination', () => { + it('mounts the host before replacing it with the notification session, from a cold navigator', () => { + const target = getNotificationNavigationTarget({ + hostId: 'host/one', + worktreeId: 'repo::/Users/me/orca/workspaces/feature' + }) + // Cold start: the tap is handled before the root navigator has committed any state. + const harness = navigationHarness(undefined) + const push = vi.fn() + + navigateToHostStackRoute( + harness.navigation, + { push, replace: vi.fn() }, + target!.hostId, + target!.sessionTarget! + ) + + expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + + harness.setState(rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] })) + harness.setState( + rootLayoutScopedState({ + index: 1, + routes: [{ name: 'index' }, { name: 'h', state: undefined }] + }) + ) + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + + harness.setState( + rootLayoutScopedState({ + index: 1, + routes: [ + { name: 'index' }, + { + name: 'h', + state: { + key: '/h', + index: 0, + routes: [ + { + key: 'host-index', + name: '[hostId]/index', + params: { hostId: encodeURIComponent('host/one') } + } + ] + } + } + ] + }) + ) + + expect(harness.navigation.dispatch).toHaveBeenCalledWith({ + type: 'REPLACE', + target: '/h', + source: 'host-index', + payload: target!.sessionTarget + }) + }) + + it('leaves a host-only notification as a shallow push with nothing to coordinate', () => { + expect(getNotificationNavigationTarget({ hostId: 'host-1' })?.sessionTarget).toBeNull() + }) + + it('routes notification taps through the coordinated transition, not a bare push', () => { + const start = rootLayoutSource.indexOf('// ─── Notification tap routing ───') + const end = rootLayoutSource.indexOf('// ─── End notification tap routing ───', start) + + // Assert the markers first: a renamed banner would otherwise slice garbage and report a + // missing call instead of the real cause. + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + + const notificationEffect = rootLayoutSource.slice(start, end) + expect(notificationEffect).toContain('openNotificationRoute(target)') + expect(notificationEffect).not.toContain('router.push(') + }) +}) diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts index ebf09e74d30..6b5762aefb4 100644 --- a/mobile/src/notifications/notification-routing.test.ts +++ b/mobile/src/notifications/notification-routing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildLocalNotificationData, getNotificationNavigationPath } from './notification-routing' +import { buildLocalNotificationData, getNotificationNavigationTarget } from './notification-routing' describe('notification routing', () => { it('includes the host id in locally scheduled notification data', () => { @@ -20,26 +20,36 @@ describe('notification routing', () => { }) }) + // Identities stay raw: the target is dispatched as navigator params, not a URL. it('routes notification taps to the worktree terminal screen', () => { expect( - getNotificationNavigationPath({ + getNotificationNavigationTarget({ hostId: 'host-1', worktreeId: 'repo::/Users/me/orca/workspaces/feature' }) - ).toBe('/h/host-1/session/repo%3A%3A%2FUsers%2Fme%2Forca%2Fworkspaces%2Ffeature') + ).toEqual({ + hostId: 'host-1', + sessionTarget: { + name: '[hostId]/session/[worktreeId]', + params: { hostId: 'host-1', worktreeId: 'repo::/Users/me/orca/workspaces/feature' } + } + }) }) it('falls back to the host screen when the payload has no worktree id', () => { - expect(getNotificationNavigationPath({ hostId: 'host-1' })).toBe('/h/host-1') + expect(getNotificationNavigationTarget({ hostId: 'host-1' })).toEqual({ + hostId: 'host-1', + sessionTarget: null + }) }) it('ignores payloads that cannot identify the paired host', () => { - expect(getNotificationNavigationPath({ worktreeId: 'repo::/tmp/worktree' })).toBeNull() + expect(getNotificationNavigationTarget({ worktreeId: 'repo::/tmp/worktree' })).toBeNull() }) it('ignores payloads for hosts that are no longer paired', () => { expect( - getNotificationNavigationPath( + getNotificationNavigationTarget( { hostId: 'removed-host', worktreeId: 'repo::/tmp/worktree' }, { knownHostIds: new Set(['host-1']) } ) diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts index f38a06ec178..feda52c596b 100644 --- a/mobile/src/notifications/notification-routing.ts +++ b/mobile/src/notifications/notification-routing.ts @@ -1,3 +1,6 @@ +import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' +import { mobileSessionRouteTarget } from '../session/mobile-session-route' + export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test' export type DesktopNotificationEvent = { @@ -38,10 +41,17 @@ export function buildLocalNotificationData( return data } -export function getNotificationNavigationPath( +/** Where a tap should land. `sessionTarget` is null for a host-only notification, whose + * `/h/` push is shallow enough to need no host-stack coordination. */ +export type NotificationNavigationTarget = Readonly<{ + hostId: string + sessionTarget: HostStackRouteTarget | null +}> + +export function getNotificationNavigationTarget( data: unknown, options: NotificationNavigationOptions = {} -): string | null { +): NotificationNavigationTarget | null { if (!data || typeof data !== 'object') { return null } @@ -55,11 +65,9 @@ export function getNotificationNavigationPath( return null } - const hostPath = `/h/${encodeURIComponent(hostId)}` const worktreeId = readNonEmptyString(record.worktreeId) - if (!worktreeId) { - return hostPath + return { + hostId, + sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null } - - return `${hostPath}/session/${encodeURIComponent(worktreeId)}` } diff --git a/mobile/src/notifications/use-open-notification-route.ts b/mobile/src/notifications/use-open-notification-route.ts new file mode 100644 index 00000000000..3a0809c7be8 --- /dev/null +++ b/mobile/src/notifications/use-open-notification-route.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react' +import { useRouter } from 'expo-router' +import { hostStackHostRoute } from '../navigation/host-stack-navigation' +import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' +import type { NotificationNavigationTarget } from './notification-routing' + +export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void { + const openHostStackRoute = useOpenHostStackRoute() + const router = useRouter() + + return useCallback( + (target) => { + if (target.sessionTarget) { + openHostStackRoute(target.hostId, target.sessionTarget) + return + } + router.push(hostStackHostRoute(target.hostId)) + }, + [openHostStackRoute, router] + ) +} diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index 86fcb0dc4a2..68258c22820 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -187,7 +187,7 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac disabled={!canAdvance} > - {isLast ? 'Send answer' : 'Next'} + {isLast ? 'Submit' : 'Next'} diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 4cbc955a9f4..4ee001ca4af 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -1,6 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { radii, spacing } from '../theme/mobile-theme' import { MobileNativeChatComposer } from './MobileNativeChatComposer' vi.mock('react-native', async () => { @@ -8,6 +9,7 @@ vi.mock('react-native', async () => { return { ActivityIndicator: 'ActivityIndicator', Image: 'Image', + Keyboard: { dismiss: vi.fn() }, Pressable: 'Pressable', ScrollView: ({ children, ...props }: { children?: unknown }) => React.createElement('ScrollView', props, children), @@ -23,12 +25,24 @@ vi.mock('react-native', async () => { vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', ImagePlus: 'ImagePlus', Mic: 'Mic', Square: 'Square', X: 'X' })) +vi.mock('../components/BottomDrawer', async () => { + const React = await import('react') + return { + BottomDrawer: ({ visible, children }: { visible: boolean; children?: unknown }) => + visible ? React.createElement('BottomDrawer', { visible }, children) : null + } +}) + function suppressRendererWarning(): () => void { const original = console.error const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { @@ -90,10 +104,149 @@ describe('MobileNativeChatComposer', () => { await act(async () => sendButton().props.onPress()) - expect(onSend).toHaveBeenCalledWith('hello') + expect(onSend).toHaveBeenCalledWith(' hello') expect(onChangeText).not.toHaveBeenCalled() }) + it('stacks the input above the composer action row', async () => { + await render(vi.fn().mockResolvedValue(true), vi.fn()) + + const composer = renderer!.root.findByProps({ testID: 'native-chat-composer' }) + const inset = renderer!.root.findByProps({ testID: 'native-chat-composer-inset' }) + const actions = renderer!.root.findByProps({ testID: 'native-chat-composer-actions' }) + expect(composer.findAllByType('TextInput')).toHaveLength(1) + expect(composer.children[1]).toBe(actions) + expect(inset.props.style).toMatchObject({ + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.md + }) + expect(composer.props.style).toMatchObject({ + borderWidth: 1, + borderRadius: radii.card, + overflow: 'hidden' + }) + }) + + it('preserves leading whitespace so prose is not turned into a slash command', async () => { + const onSend = vi.fn().mockResolvedValue(true) + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' /clear is prose ', + onChangeText: vi.fn(), + onSend + }) + ) + }) + } finally { + restore() + } + await act(async () => sendButton().props.onPress()) + expect(onSend).toHaveBeenCalledWith(' /clear is prose') + }) + + it('locks the option pickers while a composer send is in flight', async () => { + // The reverse of the test below. The host spaces a send's body and its Enter + // ~500ms apart, so an apply tapped inside that window would be submitted as + // part of the user's prompt instead of running as its own command. + let releaseSend: ((accepted: boolean) => void) | undefined + const onSend = vi.fn( + () => + new Promise((resolve) => { + releaseSend = resolve + }) + ) + const controller = { + snapshot: [ + { + id: 'model', + label: 'Model', + category: 'model' as const, + kind: { + type: 'select' as const, + choices: [ + { value: 'sonnet', label: 'Sonnet 5' }, + { value: 'opus', label: 'Opus 4.8' } + ] + }, + valueSource: 'unknown' as const, + settable: true + } + ], + pendingId: null, + setOption: vi.fn(), + invokeAction: vi.fn(), + recordCommand: vi.fn() + } + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'run the tests', + onChangeText: vi.fn(), + onSend, + sessionOptions: { isWorking: false, controller } + }) + ) + }) + const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } => + renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model' + ) as { props: { accessibilityState: { disabled: boolean } } } + expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) + // Start the send but don't await it — it stays in flight on purpose. + let pressed!: Promise + await act(async () => { + pressed = sendButton().props.onPress() + await Promise.resolve() + }) + expect(onSend).toHaveBeenCalled() + expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true }) + await act(async () => { + releaseSend?.(true) + await pressed + }) + expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) + } finally { + restore() + } + }) + + it('blocks composer submission while a session-option command is pending', async () => { + const onSend = vi.fn().mockResolvedValue(true) + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'hello', + onChangeText: vi.fn(), + onSend, + sessionOptions: { + isWorking: false, + controller: { + snapshot: [], + pendingId: 'model', + setOption: vi.fn(), + invokeAction: vi.fn(), + recordCommand: vi.fn() + } + } + }) + ) + }) + } finally { + restore() + } + expect(sendButton().props).toMatchObject({ disabled: true }) + await act(async () => sendButton().props.onPress()) + expect(onSend).not.toHaveBeenCalled() + }) + it('keeps the draft when the send is rejected', async () => { const onChangeText = vi.fn() const onSend = vi.fn().mockResolvedValue(false) @@ -101,7 +254,7 @@ describe('MobileNativeChatComposer', () => { await act(async () => sendButton().props.onPress()) - expect(onSend).toHaveBeenCalledWith('hello') + expect(onSend).toHaveBeenCalledWith(' hello') expect(onChangeText).not.toHaveBeenCalled() }) @@ -202,7 +355,8 @@ describe('MobileNativeChatComposer', () => { createElement(MobileNativeChatComposer, { value: '/c', onChangeText: vi.fn(), - onSend: vi.fn().mockResolvedValue(true) + onSend: vi.fn().mockResolvedValue(true), + agent: 'claude' }) ) }) @@ -235,6 +389,36 @@ describe('MobileNativeChatComposer', () => { expect(input().props.selection).toBeUndefined() }) + it('serves the active agent’s shared command catalog with descriptions', async () => { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + agent: 'codex' + }) + ) + }) + } finally { + restore() + } + const input = renderer!.root.find((node) => node.type === 'TextInput') as { + props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void } + } + await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 1 } } })) + const texts = renderer!.root + .findAll((node) => node.type === 'Text') + .map((node) => (node.props as { children?: unknown }).children) + // Codex-only commands from the shared catalog, with their description rows — + // and none of the old hardcoded provider-agnostic list's phantom entries. + expect(texts).toContain('/permissions') + expect(texts).toContain('Choose what Codex is allowed to do') + expect(texts).not.toContain('/cost') + }) + it('wires the mic for hold vs toggle dictation like the terminal composer', async () => { const onMicPress = vi.fn() const onMicPressIn = vi.fn() diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 202a0ae5398..9b289b4b6e8 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -5,32 +5,29 @@ import { Pressable, ScrollView, StyleSheet, - Text, TextInput, View } from 'react-native' import { ArrowUp, ImagePlus, Mic, Square, X } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { getVerifiedNativeChatCommands } from '../../../src/shared/native-chat-agent-profiles' import { applyAutocomplete, detectAutocompleteTrigger, + rankSlashCommandSuggestions, rankSuggestions } from './mobile-native-chat-autocomplete' +import { + composerSuggestionInsertText, + MobileNativeChatComposerSuggestions, + type ComposerSuggestion +} from './MobileNativeChatComposerSuggestions' +import { + MobileNativeChatSessionOptionPickers, + type MobileNativeChatSessionOptionPickersProps +} from './MobileNativeChatSessionOptionPickers' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' -// Common agent slash commands offered as autocomplete; sending them is just text -// to the agent's terminal, so the set is intentionally provider-agnostic. -const SLASH_COMMANDS = [ - '/clear', - '/compact', - '/review', - '/model', - '/help', - '/init', - '/cost', - '/diff' -] - const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] @@ -39,6 +36,11 @@ type Props = { value: string onChangeText: (text: string) => void onSend: (text: string) => Promise + /** Active tab's agent — the slash autocomplete serves its command catalog. */ + agent?: string | null + /** Model/session-option pickers shown in the composer action row; null when + * the agent has no session-option catalog. */ + sessionOptions?: MobileNativeChatSessionOptionPickersProps | null onAttachImage?: () => void /** Images picked-and-uploaded but not yet sent — shown as removable thumbnails * and ridden along on the next send (desktop native-chat parity). */ @@ -61,6 +63,8 @@ export function MobileNativeChatComposer({ value, onChangeText, onSend, + agent, + sessionOptions, onAttachImage, attachments = NO_ATTACHMENTS, onRemoveAttachment, @@ -85,21 +89,36 @@ export function MobileNativeChatComposer({ const sendingRef = useRef(false) const [sending, setSending] = useState(false) const trimmed = value.trim() + const sessionOptionDispatching = sessionOptions?.controller.pendingId != null // An attached image alone is a valid send (desktop parity), so the image rides // along even when the user sends no accompanying text. const canSend = - (trimmed.length > 0 || attachments.length > 0) && !disabled && !sending && !isAttaching + (trimmed.length > 0 || attachments.length > 0) && + !disabled && + !sending && + !isAttaching && + !sessionOptionDispatching const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) - const suggestions = useMemo(() => { + const suggestions = useMemo(() => { if (!trigger) { return [] } if (trigger.kind === 'slash') { - return rankSuggestions(SLASH_COMMANDS, trigger.query) + const commands = agent ? getVerifiedNativeChatCommands(agent) : [] + // Why: Codex's catalog is 45 commands and this list is a plain ScrollView + // (~5 rows visible), so an uncapped `/` would mount every row and + // re-reconcile them on each streaming tick right above the transcript. + return rankSlashCommandSuggestions(commands, trigger.query, 12).map((command) => ({ + kind: 'command' as const, + command + })) } - return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`) - }, [trigger, filePaths]) + return rankSuggestions(filePaths, trigger.query).map((path) => ({ + kind: 'file' as const, + path + })) + }, [trigger, filePaths, agent]) useEffect(() => { if (trigger?.kind === 'file') { @@ -111,11 +130,15 @@ export function MobileNativeChatComposer({ onChangeText(next) } - const pickSuggestion = (suggestion: string): void => { + const pickSuggestion = (suggestion: ComposerSuggestion): void => { if (!trigger) { return } - const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion) + const { text: nextText, cursor: nextCursor } = applyAutocomplete( + value, + trigger, + composerSuggestionInsertText(suggestion) + ) onChangeText(nextText) setCursor(nextCursor) setPendingSelection({ start: nextCursor, end: nextCursor }) @@ -128,7 +151,7 @@ export function MobileNativeChatComposer({ sendingRef.current = true setSending(true) try { - const accepted = await onSend(trimmed) + const accepted = await onSend(value.trimEnd()) if (accepted) { setCursor(0) } @@ -141,21 +164,7 @@ export function MobileNativeChatComposer({ return ( {suggestions.length > 0 ? ( - - - {suggestions.map((s) => ( - [styles.suggestion, pressed && styles.suggestionPressed]} - onPress={() => pickSuggestion(s)} - > - - {s} - - - ))} - - + ) : null} {attachments.length > 0 ? ( ) : null} - - {onAttachImage ? ( - [styles.iconButton, pressed && styles.pressed]} - onPress={onAttachImage} - disabled={isAttaching || disabled} - > - {isAttaching ? ( - - ) : ( - - )} - - ) : null} - { - setCursor(e.nativeEvent.selection.end) - setPendingSelection(null) - }} - placeholder={placeholder} - placeholderTextColor={colors.textMuted} - selectionColor={colors.accentBlue} - multiline - // Why: never revoke `editable` — iOS resigns first responder on a focused - // field, so a transient lock would yank the keyboard mid-typing (#10681). - // The lock gates sending; the draft survives and rides the next send. - textAlignVertical="top" - /> - {onMicPress ? ( - [styles.iconButton, pressed && styles.pressed]} - // Hold mode is walkie-talkie (press-in/out); toggle mode taps. - onPress={dictationMode === 'hold' ? undefined : onMicPress} - onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} - onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} - disabled={disabled} - > - {micActive ? ( - + + { + setCursor(e.nativeEvent.selection.end) + setPendingSelection(null) + }} + placeholder={placeholder} + placeholderTextColor={colors.textMuted} + selectionColor={colors.accentBlue} + multiline + // Why: never revoke `editable` — iOS resigns first responder on a focused + // field, so a transient lock would yank the keyboard mid-typing (#10681). + // The lock gates sending; the draft survives and rides the next send. + textAlignVertical="top" + /> + + {onAttachImage ? ( + [styles.iconButton, pressed && styles.pressed]} + onPress={onAttachImage} + disabled={isAttaching || disabled} + > + {isAttaching ? ( + + ) : ( + + )} + + ) : null} + {sessionOptions ? ( + - ) : ( - - )} - - ) : null} - [ - styles.sendButton, - !canSend && styles.sendButtonDisabled, - pressed && canSend && styles.pressed - ]} - onPress={handleSend} - disabled={!canSend} - > - - + ) : null} + + {onMicPress ? ( + [styles.iconButton, pressed && styles.pressed]} + // Hold mode is walkie-talkie (press-in/out); toggle mode taps. + onPress={dictationMode === 'hold' ? undefined : onMicPress} + onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} + onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} + disabled={disabled} + > + {micActive ? ( + + ) : ( + + )} + + ) : null} + [ + styles.sendButton, + !canSend && styles.sendButtonDisabled, + pressed && canSend && styles.pressed + ]} + onPress={handleSend} + disabled={!canSend} + > + + + + ) } const styles = StyleSheet.create({ - suggestions: { - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - suggestionScroll: { - maxHeight: 180 - }, - suggestion: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle - }, - suggestionPressed: { - backgroundColor: colors.bgRaised - }, - suggestionText: { - color: colors.textPrimary, - fontFamily: typography.monoFamily, - fontSize: typography.metaSize - }, attachmentStrip: { maxHeight: 76, borderTopWidth: StyleSheet.hairlineWidth, @@ -321,18 +323,32 @@ const styles = StyleSheet.create({ borderWidth: StyleSheet.hairlineWidth, borderColor: colors.borderSubtle }, + composerInset: { + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.md + }, bar: { - flexDirection: 'row', - alignItems: 'flex-end', - gap: spacing.sm, + gap: spacing.xs, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.borderSubtle, - backgroundColor: colors.bgPanel + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + overflow: 'hidden' + }, + actionRow: { + minHeight: 40, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + actionSpacer: { + flex: 1 }, input: { - flex: 1, + width: '100%', maxHeight: 140, minHeight: 40, color: colors.textPrimary, diff --git a/mobile/src/session/MobileNativeChatComposerSuggestions.tsx b/mobile/src/session/MobileNativeChatComposerSuggestions.tsx new file mode 100644 index 00000000000..a9ac71f33ea --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposerSuggestions.tsx @@ -0,0 +1,82 @@ +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands' + +/** One row of the composer autocomplete: an agent slash command (with its + * catalog description, desktop parity) or a worktree file path. */ +export type ComposerSuggestion = + | { kind: 'command'; command: SlashCommandSuggestion } + | { kind: 'file'; path: string } + +export function composerSuggestionKey(suggestion: ComposerSuggestion): string { + return suggestion.kind === 'command' + ? `command:${suggestion.command.name}` + : `file:${suggestion.path}` +} + +/** The text the suggestion inserts at the trigger span. */ +export function composerSuggestionInsertText(suggestion: ComposerSuggestion): string { + return suggestion.kind === 'command' ? `/${suggestion.command.name}` : `@${suggestion.path}` +} + +export function MobileNativeChatComposerSuggestions({ + suggestions, + onPick +}: { + suggestions: readonly ComposerSuggestion[] + onPick: (suggestion: ComposerSuggestion) => void +}): React.JSX.Element { + return ( + + + {suggestions.map((suggestion) => ( + [styles.suggestion, pressed && styles.suggestionPressed]} + onPress={() => onPick(suggestion)} + > + + {composerSuggestionInsertText(suggestion)} + + {suggestion.kind === 'command' && suggestion.command.description ? ( + + {suggestion.command.description} + + ) : null} + + ))} + + + ) +} + +const styles = StyleSheet.create({ + suggestions: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + suggestionScroll: { + maxHeight: 220 + }, + suggestion: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle, + gap: 1 + }, + suggestionPressed: { + backgroundColor: colors.bgRaised + }, + suggestionText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: typography.metaSize + }, + suggestionDescription: { + color: colors.textSecondary, + fontSize: typography.metaSize + } +}) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index bb687b77ba6..9d33db069ff 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -1,7 +1,8 @@ import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary' vi.mock('react-native', async () => { const React = await import('react') @@ -30,7 +31,11 @@ function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage { return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' } } -describe('MobileNativeChatMessage image-ref rendering', () => { +function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage { + return { id: 'a1', role: 'assistant', blocks, timestamp: null, source: 'transcript' } +} + +describe('MobileNativeChatMessage', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { @@ -41,7 +46,10 @@ describe('MobileNativeChatMessage image-ref rendering', () => { renderer = null }) - function render(message: NativeChatMessage): ReactTestRenderer { + function render( + message: NativeChatMessage, + props: { toolsExpanded?: boolean } = {} + ): ReactTestRenderer { const original = console.error const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { @@ -51,7 +59,7 @@ describe('MobileNativeChatMessage image-ref rendering', () => { }) try { act(() => { - renderer = create(createElement(MobileNativeChatMessage, { message })) + renderer = create(createElement(MobileNativeChatMessage, { message, ...props })) }) } finally { spy.mockRestore() @@ -59,6 +67,9 @@ describe('MobileNativeChatMessage image-ref rendering', () => { return renderer! } + const textIn = (node: ReactTestInstance): string[] => + node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) + it('renders a loadable preview URI as an image thumbnail', () => { const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }])) const image = tree.root.findByType('Image' as never) @@ -84,4 +95,75 @@ describe('MobileNativeChatMessage image-ref rendering', () => { .map((node) => String(node.children.join(''))) expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true) }) + + it('labels a tool row with the target path instead of raw input JSON', () => { + const tree = render( + toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]), + { toolsExpanded: true } + ) + const texts = textIn(tree.root) + expect(texts).toContain('src/index.ts') + expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false) + }) + + it('bounds expanded diff-less tool input before native text layout', () => { + const tree = render( + toolMessage([ + { type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } } + ]), + { toolsExpanded: true } + ) + const detail = textIn(tree.root).find((text) => text.startsWith('{\n')) + expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1) + expect(detail?.endsWith('…')).toBe(true) + }) + + it('expands formatted detail for a collapsed JSON-string tool input', () => { + const tree = render( + toolMessage([ + { + type: 'tool-call', + name: 'CustomTool', + input: '{"cmd":"git status","description":"Inspect changes"}' + } + ]) + ) + const pressableWith = (label: string): ReactTestInstance => + tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))! + + act(() => pressableWith('1×').props.onPress()) + // The row label is the command, and the detail stays closed until tapped. + expect(textIn(tree.root)).toContain('git status') + expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false) + + act(() => pressableWith('CustomTool').props.onPress()) + expect(textIn(tree.root)).toContain( + '{\n "cmd": "git status",\n "description": "Inspect changes"\n}' + ) + }) + + it('does not echo the row label as detail when a row has nothing to expand', () => { + // The Tools toggle opens every row at once, bypassing the tap guard — a row + // whose formatted input is its own label would echo itself in a panel that + // no tap can dismiss. + const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), { + toolsExpanded: true + }) + expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1) + // The chevron has to agree with the panel, or the row claims to be open over + // nothing and the tap that would close it is guarded off. Only the run header + // is open here; the row itself stays collapsed. + expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1) + expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1) + }) + + it('does not expand a plain input that already fits in the row label', () => { + const input = 'x'.repeat(60) + const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), { + toolsExpanded: true + }) + expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1) + expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1) + expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1) + }) }) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index d8458f1b889..d9c9f6100ca 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -14,12 +14,12 @@ import { } from './mobile-native-chat-blocks' import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff' import { isRenderableImageUri } from './mobile-native-chat-image-preview' -import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' import { nativeChatMessageText } from './mobile-native-chat-message-text' import { - summarizeToolInput, + createToolInputDisplay, summarizeToolRun, - toolFilePath + truncateToolDetail } from './mobile-native-chat-tool-summary' const MAX_VISIBLE_TOOL_PAIRS = 6 @@ -63,11 +63,7 @@ function ResultBody({ } return ( - - {output.length > MAX_TOOL_RESULT_CHARS - ? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…` - : output} - + {truncateToolDetail(output)} ) } @@ -88,17 +84,21 @@ function ToolLine({ const [expanded, setExpanded] = useState(defaultExpanded) const { call, result } = pair const name = call ? call.name : 'Result' - const preview = call - ? summarizeToolInput(call.input) - : (result?.output.split('\n')[0]?.slice(0, 80) ?? '') + const inputDisplay = call ? createToolInputDisplay(call.input) : null + const preview = inputDisplay?.label ?? result?.output.split('\n')[0]?.slice(0, 80) ?? '' // Why: collapsed tool rows are the common path; defer bounded diff parsing - // until the user asks to reveal the detail. + // and detail formatting until the user asks to reveal the detail. const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null - const hasDetail = callDiff !== null || result !== undefined || preview.length > 40 + const callDetail = expanded && inputDisplay && !callDiff ? inputDisplay.formatDetail() : undefined + const hasDetail = callDiff !== null || result !== undefined || inputDisplay?.hasDetail === true + // The group toggle opens every line at once, bypassing the tap guard, so the + // panel has to consult it too — else a detail-less row echoes its own label + // under itself and no tap can dismiss it. + const showDetail = hasDetail && expanded // A tool that targets a file (Read/Edit/Write…) renders its preview as a // tappable link that opens the file, independent of the line's expand tap. - const filePath = call ? toolFilePath(call.input) : null + const filePath = inputDisplay?.filePath ?? null const openable = filePath !== null && onOpenFile !== undefined return ( @@ -107,7 +107,7 @@ function ToolLine({ onPress={() => hasDetail && setExpanded((v) => !v)} hitSlop={6} > - {expanded ? ( + {showDetail ? ( ) : ( @@ -124,10 +124,10 @@ function ToolLine({ ) : null} - {expanded ? ( + {showDetail ? ( {callDiff ? : null} - {!callDiff && call && preview ? {preview} : null} + {callDetail ? {callDetail} : null} {result ? ( ) : null} diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 96713c71a0a..04b0630f022 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -52,6 +52,7 @@ function overlayElement(tick: Tick): ReturnType { nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], + chatImagePreviewsByMessageId: {}, chatComposerText: '', setChatComposerText: vi.fn() } as unknown as MobileNativeChatController diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 1fbdf97bc8d..6e2806c488a 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -1,7 +1,8 @@ import { useMemo } from 'react' import { StyleSheet, View } from 'react-native' -import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' +import { MobileNativeChatView } from './MobileNativeChatView' import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' +import type { MobileNativeChatInputLockReason } from './use-mobile-native-chat-control-state' import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble' @@ -70,6 +71,8 @@ export function MobileNativeChatOverlay({ streaming={streaming} onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} + askKey={controller.nativeChatAskKey} + onDismissAsk={controller.dismissNativeChatAsk} onAnswerAsk={controller.handleNativeChatAnswerAsk} onCancelAsk={controller.handleNativeChatCancelAsk} question={controller.nativeChatQuestion} @@ -82,6 +85,7 @@ export function MobileNativeChatOverlay({ onLoadEarlier={session.loadEarlier} onSend={images.sendNativeChat} pending={controller.chatPending} + imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} composerText={controller.chatComposerText} onComposerTextChange={controller.setChatComposerText} onAttachImage={() => void images.attachImage('library')} @@ -98,6 +102,7 @@ export function MobileNativeChatOverlay({ onClearSendError={onClearSendError} filePaths={controller.nativeChatFilePaths} onNeedFiles={controller.loadNativeChatFiles} + sessionOptions={controller.nativeChatSessionOptions} keyboardInset={keyboardInset} /> diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts new file mode 100644 index 00000000000..f5af434fb92 --- /dev/null +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts @@ -0,0 +1,244 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' +import { MobileNativeChatSessionOptionPickers } from './MobileNativeChatSessionOptionPickers' +import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Keyboard: { dismiss: vi.fn() }, + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', + X: 'X' +})) +vi.mock('../components/BottomDrawer', async () => { + const React = await import('react') + return { + BottomDrawer: ({ visible, children }: { visible: boolean; children?: unknown }) => + visible ? React.createElement('BottomDrawer', { visible }, children) : null + } +}) + +const MODEL_DESCRIPTOR: SessionOptionDescriptor = { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'sonnet', + choices: [ + { value: 'sonnet', label: 'Sonnet 5' }, + { value: 'opus', label: 'Opus 4.8', description: 'Most capable' } + ] + }, + valueSource: 'reported', + settable: true +} + +const EFFORT_DESCRIPTOR: SessionOptionDescriptor = { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + currentValue: 'high', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + valueSource: 'dispatched', + settable: true +} + +const FAST_MODE_DESCRIPTOR: SessionOptionDescriptor = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', currentValue: false }, + valueSource: 'reported', + settable: true +} + +describe('MobileNativeChatSessionOptionPickers', () => { + let renderer: ReactTestRenderer | null = null + const setOption = vi.fn() + const invokeAction = vi.fn() + + const mount = (snapshot: SessionOptionDescriptor[], isWorking = false): void => { + const controller: MobileNativeChatSessionOptionsController = { + snapshot, + pendingId: null, + setOption, + invokeAction, + recordCommand: vi.fn() + } + act(() => { + renderer = create( + createElement(MobileNativeChatSessionOptionPickers, { controller, isWorking }) + ) + }) + } + + const pill = ( + name: string + ): { + props: { + onPress: () => void + accessibilityLabel?: string + disabled?: boolean + accessibilityRole?: string + accessibilityState?: { disabled?: boolean } + } + } => + renderer!.root.find( + (node) => + node.type === 'Pressable' && + typeof node.props.accessibilityLabel === 'string' && + node.props.accessibilityLabel.startsWith(name) + ) as { + props: { + onPress: () => void + accessibilityLabel?: string + disabled?: boolean + accessibilityRole?: string + accessibilityState?: { disabled?: boolean } + } + } + + const rowByText = ( + text: string + ): { + props: { + onPress: () => void + accessibilityRole?: string + accessibilityLabel?: string + accessibilityState?: { checked?: boolean; disabled?: boolean } + } + } => { + const label = renderer!.root + .findAll((node) => node.type === 'Text') + .find((node) => (node.props as { children?: unknown }).children === text) + if (!label) { + throw new Error(`No row labeled ${text}`) + } + let parent = label.parent + while (parent && parent.type !== 'Pressable') { + parent = parent.parent + } + if (!parent) { + throw new Error(`No pressable row for ${text}`) + } + return parent as unknown as { + props: { + onPress: () => void + accessibilityRole?: string + accessibilityLabel?: string + accessibilityState?: { checked?: boolean; disabled?: boolean } + } + } + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setOption.mockReset() + setOption.mockResolvedValue(true) + invokeAction.mockReset() + invokeAction.mockResolvedValue(true) + }) + afterEach(() => { + act(() => { + renderer?.unmount() + }) + renderer = null + }) + + it('renders nothing without a model descriptor', () => { + mount([]) + expect(renderer!.toJSON()).toBeNull() + }) + + it('shows the current model and effort in one pill', () => { + mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR]) + expect(pill('Model').props).toMatchObject({ + accessibilityLabel: 'Model, Sonnet 5 High', + disabled: false + }) + const labels = renderer!.root + .findAll((node) => node.type === 'Text') + .map((node) => (node.props as { children?: unknown }).children) + expect(labels).toContain('Sonnet 5 High') + }) + + it('opens the model sheet and applies a picked model', async () => { + mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR]) + await act(async () => pill('Model').props.onPress()) + expect(renderer!.root.findByType('BottomDrawer').props.visible).toBe(true) + await act(async () => rowByText('Opus 4.8').props.onPress()) + expect(setOption).toHaveBeenCalledWith('model', 'opus') + }) + + it('opens an option picker from the model sheet summary', async () => { + mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR]) + await act(async () => pill('Model').props.onPress()) + await act(async () => rowByText('Effort').props.onPress()) + await act(async () => rowByText('Low').props.onPress()) + expect(setOption).toHaveBeenCalledWith('effort', 'low') + }) + + it('shows absolute boolean values in option summaries', async () => { + mount([MODEL_DESCRIPTOR, FAST_MODE_DESCRIPTOR]) + await act(async () => pill('Model').props.onPress()) + expect(rowByText('Off').props.accessibilityLabel).toBe('Fast mode, Off') + }) + + it('announces choice selection and disabled state', async () => { + mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR]) + expect(pill('Model').props).toMatchObject({ + accessibilityRole: 'button', + accessibilityState: { disabled: false } + }) + await act(async () => pill('Model').props.onPress()) + expect(rowByText('Sonnet 5').props).toMatchObject({ + accessibilityRole: 'radio', + accessibilityState: { checked: true, disabled: false } + }) + expect(rowByText('Opus 4.8').props.accessibilityState?.checked).toBe(false) + }) + + it('closes without dispatch when re-picking the tracked value', async () => { + mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR]) + await act(async () => pill('Model').props.onPress()) + await act(async () => rowByText('Sonnet 5').props.onPress()) + expect(setOption).not.toHaveBeenCalled() + }) + + it('renders agent-picker descriptors as a single action row', async () => { + mount([ + { + ...MODEL_DESCRIPTOR, + kind: { type: 'select', choices: [] }, + valueSource: 'unknown', + action: { type: 'agent-picker' } + } + ]) + await act(async () => pill('Model').props.onPress()) + expect(rowByText('Choose in agent picker…').props.accessibilityRole).toBe('button') + await act(async () => rowByText('Choose in agent picker…').props.onPress()) + expect(invokeAction).toHaveBeenCalledWith('model') + }) + + it('locks the pills while the agent is working', () => { + mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true) + expect(pill('Model').props).toMatchObject({ disabled: true }) + }) +}) diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx new file mode 100644 index 00000000000..bfa5244a398 --- /dev/null +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react' +import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from 'react-native' +import { ChevronLeft, X } from 'lucide-react-native' +import { BottomDrawer } from '../components/BottomDrawer' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { + SessionOptionDescriptor, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { + mobileModelPillLabel, + mobileOptionsPillLabel, + mobileSessionOptionSummaryValue, + mobileSessionOptionDisabledReason +} from './mobile-native-chat-session-option-labels' +import { + DescriptorRows, + Pill, + SessionOptionCaption, + SessionOptionSummaryRow +} from './MobileNativeChatSessionOptionRows' +import { sortNativeChatSessionOptions } from '../../../src/shared/native-chat-session-option-snapshot' +import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' + +export type MobileNativeChatSessionOptionPickersProps = { + controller: MobileNativeChatSessionOptionsController + /** Pickers lock while the agent works — a mid-turn `/model` interleaves with + * the agent's own output (desktop parity). */ + isWorking: boolean + /** A composer send owns the TUI input line until it settles. The host spaces a + * send's body and its Enter ~500ms apart, so an apply dispatched inside that + * window would be submitted as part of the user's prompt. The composer blocks + * the reverse direction on `pendingId`; this is the same guard mirrored. */ + sendInFlight?: boolean +} + +/** Combined model/session-option trigger and its mobile bottom drawer. */ +export function MobileNativeChatSessionOptionPickers({ + controller, + isWorking, + sendInFlight = false +}: MobileNativeChatSessionOptionPickersProps): React.JSX.Element | null { + const [openDescriptorId, setOpenDescriptorId] = useState(null) + const { snapshot, pendingId } = controller + const model = snapshot.find((descriptor) => descriptor.category === 'model') + const options = sortNativeChatSessionOptions(snapshot) + if (!model) { + return null + } + const disabled = isWorking || pendingId !== null || sendInFlight + const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId) + const modelView = activeDescriptor?.id === model.id + const modelLabel = mobileModelPillLabel(model) + const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null + const pillLabel = optionsLabel ? `${modelLabel} ${optionsLabel}` : modelLabel + const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason) + + const closePicker = (): void => setOpenDescriptorId(null) + const openPicker = (): void => { + Keyboard.dismiss() + setOpenDescriptorId(model.id) + } + + const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { + // Re-picking the tracked value is a no-op — never re-dispatch it. + if ( + descriptor.valueSource !== 'unknown' && + descriptor.kind.type === 'select' && + descriptor.kind.currentValue === value + ) { + closePicker() + return + } + void controller.setOption(descriptor.id, value).then((applied) => { + if (applied) { + closePicker() + } + }) + } + const invokeAction = (descriptor: SessionOptionDescriptor): void => { + void controller.invokeAction(descriptor.id).then((invoked) => { + if (invoked) { + closePicker() + } + }) + } + + return ( + + + + {activeDescriptor ? ( + + + [styles.sheetNav, pressed && styles.pressed]} + onPress={modelView ? closePicker : () => setOpenDescriptorId(model.id)} + hitSlop={8} + > + {modelView ? ( + + ) : ( + + )} + + + {modelView ? 'Select model' : `Select ${activeDescriptor.label.toLowerCase()}`} + + + {pendingId !== null ? ( + + ) : null} + + + {activeDescriptor.valueSource === 'dispatched' ? ( + Sent to the agent — not confirmed + ) : null} + {reason ? {reason} : null} + + applyOption(activeDescriptor, value)} + onInvokeAction={() => invokeAction(activeDescriptor)} + /> + + {modelView && options.length > 0 ? ( + + {options.map((descriptor, index) => ( + setOpenDescriptorId(descriptor.id)} + /> + ))} + + ) : null} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + sheet: { + paddingBottom: spacing.xs + }, + sheetHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: spacing.lg + }, + sheetTitle: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.titleSize, + fontWeight: '700', + textAlign: 'center' + }, + sheetNav: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + sheetHeaderSide: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center' + }, + choiceGroup: { + overflow: 'hidden', + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised + }, + optionGroup: { + overflow: 'hidden', + marginTop: spacing.md, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatSessionOptionRows.tsx b/mobile/src/session/MobileNativeChatSessionOptionRows.tsx new file mode 100644 index 00000000000..468403b7304 --- /dev/null +++ b/mobile/src/session/MobileNativeChatSessionOptionRows.tsx @@ -0,0 +1,345 @@ +// The pill and choice-row primitives the session-option card is built from, kept +// beside it so the card file stays about layout and apply wiring. + +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Check, ChevronDown, ChevronRight } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { + SessionOptionDescriptor, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' + +/** Muted one-liner above a group — dispatch state, or why a row is locked. */ +export function SessionOptionCaption({ children }: { children: string }): React.JSX.Element { + return {children} +} + +export function Pill({ + label, + accessibleName, + disabled, + onPress +}: { + label: string + accessibleName: string + disabled: boolean + onPress: () => void +}): React.JSX.Element { + return ( + [styles.pill, pressed && !disabled && styles.pressed]} + onPress={onPress} + disabled={disabled} + hitSlop={6} + > + + {label} + + + + ) +} + +function ChoiceRow({ + label, + description, + selected, + disabled, + grouped, + divided, + onPress +}: { + label: string + description?: string + selected: boolean + disabled: boolean + grouped: boolean + divided: boolean + onPress: () => void +}): React.JSX.Element { + return ( + + + {selected ? : null} + + + {label} + {description ? ( + + {description} + + ) : null} + + + ) +} + +function ActionRow({ + label, + disabled, + grouped, + onPress +}: { + label: string + disabled: boolean + grouped: boolean + onPress: () => void +}): React.JSX.Element { + return ( + + + {label} + + + ) +} + +export function SessionOptionSummaryRow({ + label, + value, + disabled, + divided, + onPress +}: { + label: string + value: string + disabled: boolean + divided: boolean + onPress: () => void +}): React.JSX.Element { + return ( + [ + styles.summaryRow, + divided && styles.rowDivided, + pressed && !disabled && styles.pressed, + disabled && styles.rowDisabled + ]} + onPress={onPress} + disabled={disabled} + > + {label} + + {value} + + + + ) +} + +export function DescriptorRows({ + descriptor, + disabled, + grouped = false, + onSetOption, + onInvokeAction +}: { + descriptor: SessionOptionDescriptor + disabled: boolean + grouped?: boolean + onSetOption: (value: SessionOptionValue) => void + onInvokeAction: () => void +}): React.JSX.Element { + const locked = disabled || !descriptor.settable + // Why: flip-only without a baseline is an action — never claim On/Off. + if (descriptor.action?.type === 'toggle-command') { + return ( + + ) + } + // Why: agent-picker opens the TUI; it is not a set of radio choices. + if (descriptor.action?.type === 'agent-picker') { + return ( + + ) + } + // Unknown booleans leave both radios unselected instead of inventing truth. + if (descriptor.kind.type === 'boolean') { + const current = descriptor.kind.currentValue + return ( + <> + {current === undefined ? ( + Current value unknown — pick On or Off + ) : null} + onSetOption(true)} + /> + onSetOption(false)} + /> + + ) + } + const { currentValue, choices } = descriptor.kind + return ( + <> + {choices.map((choice, index) => ( + onSetOption(choice.value)} + /> + ))} + + ) +} + +const styles = StyleSheet.create({ + pill: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + maxWidth: 180, + minHeight: 28, + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised + }, + pillText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600', + flexShrink: 1 + }, + pillTextDisabled: { + color: colors.textMuted + }, + pressed: { + opacity: 0.7 + }, + caption: { + color: colors.textMuted, + fontSize: typography.metaSize, + paddingHorizontal: spacing.md, + paddingBottom: spacing.xs + }, + row: { + flexDirection: 'row', + gap: spacing.sm, + padding: spacing.sm, + minHeight: 44, + alignItems: 'center', + borderRadius: radii.card, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.xs + }, + rowSelected: { + borderColor: colors.statusGreen + }, + rowGrouped: { + marginBottom: 0, + borderWidth: 0, + borderRadius: 0, + backgroundColor: 'transparent' + }, + rowDivided: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + rowDisabled: { + opacity: 0.5 + }, + radio: { + width: 18, + height: 18, + borderRadius: 9, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center' + }, + radioOn: { + backgroundColor: colors.statusGreen, + borderColor: colors.statusGreen + }, + rowBody: { + flex: 1, + gap: 2 + }, + rowLabel: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + rowDescription: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + summaryRow: { + minHeight: 48, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + summaryLabel: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + summaryValue: { + maxWidth: 160, + color: colors.textSecondary, + fontSize: typography.bodySize + } +}) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index b117e869d73..bb1b5b68413 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -59,8 +59,13 @@ vi.mock('./MobileAgentWorkingIndicator', async () => { vi.mock('./MobileNativeChatComposer', async () => { const React = await import('react') return { - MobileNativeChatComposer: (props: { onSend: (text: string) => Promise }) => + MobileNativeChatComposer: (props: { + onSend: (text: string) => Promise + disabled?: boolean + placeholder?: string + }) => React.createElement('Composer', { + ...props, accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }) @@ -155,6 +160,10 @@ describe('MobileNativeChatView', () => { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } + function composer(): ReactTestInstance { + return renderer!.root.find((node) => node.type === 'Composer') + } + function bannerText(): string { const [alert, ...rest] = banners() expect(rest).toHaveLength(0) @@ -372,4 +381,42 @@ describe('MobileNativeChatView', () => { expect(listIds()).toEqual(['a1', 'streaming']) }) + + it('keeps a visible lock through a subscribed-end lease blip', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + expect(composer().props.disabled).toBe(true) + + await update({ inputLockReason: null }) + expect(composer().props.disabled).toBe(true) + await act(async () => vi.advanceTimersByTime(300)) + await update({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + + expect(composer().props.disabled).toBe(true) + expect(composer().props.placeholder).toBe('Waiting for terminal…') + } finally { + vi.useRealTimers() + } + }) + + it('unlocks after the lease stays ready', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + await update({ inputLockReason: null }) + await act(async () => vi.advanceTimersByTime(599)) + expect(composer().props.disabled).toBe(true) + + await act(async () => vi.advanceTimersByTime(1)) + + expect(composer().props.disabled).toBe(false) + expect(composer().props.placeholder).toBe('Message, @files, /commands') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 5fae40a684f..c0081e995da 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -11,7 +11,6 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' -import { canStopNativeChatAgent } from '../../../src/shared/native-chat-action-availability' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' @@ -20,11 +19,11 @@ import { mobileNativeChatEmptyState, type MobileNativeChatPendingItem } from './mobile-native-chat-render-data' -import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { MobileNativeChatMessage } from './MobileNativeChatMessage' import { MobileNativeChatAsk } from './MobileNativeChatAsk' import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' @@ -32,27 +31,12 @@ import { MobileNativeChatPermission } from './MobileNativeChatPermission' import type { MobileChatPermission } from './mobile-native-chat-permission' import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' +import { + useMobileNativeChatControlState, + type MobileNativeChatInputLockReason +} from './use-mobile-native-chat-control-state' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' -/** Why the composer input is locked: the transport is disconnected, or the - * terminal subscription has not acknowledged its input lease yet. */ -export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' - -const LOCK_FEEDBACK_DELAY_MS = 600 - -function useDelayedActivation(active: boolean): boolean { - const [delayedActive, setDelayedActive] = useState(false) - useEffect(() => { - if (!active) { - setDelayedActive(false) - return - } - const timer = setTimeout(() => setDelayedActive(true), LOCK_FEEDBACK_DELAY_MS) - return () => clearTimeout(timer) - }, [active]) - return active && delayedActive -} - type Props = { /** Raw transcript, only for telling "still loading" from "loaded and empty". */ messages: NativeChatMessage[] @@ -79,6 +63,9 @@ type Props = { /** Optimistic queued sends (owned by the route so they survive view switches). */ /** Optimistic user echoes, including any ridden-along image preview URIs. */ pending: MobileNativeChatPendingItem[] + /** Local photo URIs retained when the authoritative transcript replaces an + * optimistic image bubble. */ + imagePreviewsByMessageId?: Record /** Controlled composer text (owned by the route so dictation can write to it). */ composerText: string onComposerTextChange: (text: string) => void @@ -102,11 +89,18 @@ type Props = { onClearSendError?: () => void filePaths?: string[] onNeedFiles?: (query: string) => void + /** Model/session-option pickers for the composer action row (desktop parity). */ + sessionOptions?: MobileNativeChatSessionOptionPickersProps | null /** A pending agent question/permission detected from live status, shown as a * native card above the composer; answering sends text to the agent. */ /** Structured AskUserQuestion prompt parsed from the transcript (preferred over * the heuristic question card). */ ask?: AskPrompt | null + /** Stable key for the ask card. Dismissal state lives in the controller (it + * must survive this subtree unmounting on a chat↔terminal toggle). */ + askKey?: string | null + /** Hide the answered/dismissed ask until a different question arrives. */ + onDismissAsk?: () => void /** Deliver the ask answer as per-question selections; the send hook turns them * into selector keystrokes (Claude) or pasted label text (other agents). */ onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise @@ -138,6 +132,7 @@ export function MobileNativeChatView({ onLoadEarlier, onSend, pending, + imagePreviewsByMessageId, composerText, onComposerTextChange, onAttachImage, @@ -154,7 +149,10 @@ export function MobileNativeChatView({ onClearSendError, filePaths, onNeedFiles, + sessionOptions, ask, + askKey, + onDismissAsk, onAnswerAsk, onCancelAsk, question, @@ -167,10 +165,6 @@ export function MobileNativeChatView({ const insets = useSafeAreaInsets() const listRef = useRef>(null) const [toolsExpanded, setToolsExpanded] = useState(false) - // Dismiss the question card as soon as it's answered; the live status lingers - // briefly (the agent emits a post-tool event with the same prompt), so hide it - // until a genuinely different question arrives. - const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask) // Lift the composer clear of the keyboard, plus the bottom safe-area so it // never sits under the home indicator / nav bar (mirrors the terminal dock). const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom @@ -191,8 +185,14 @@ export function MobileNativeChatView({ // route-owned optimistic queued messages. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. const { data } = useMemo( - () => buildMobileNativeChatTransientData({ folded, streaming, pending }), - [folded, streaming, pending] + () => + buildMobileNativeChatTransientData({ + folded, + streaming, + pending, + imagePreviewsByMessageId + }), + [folded, streaming, pending, imagePreviewsByMessageId] ) // Follow the tail as the conversation grows and keep the newest message above @@ -266,16 +266,10 @@ export function MobileNativeChatView({ const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 - // Composer-lock flicker guard: on a remote link, brief connState blips or lease - // hand-offs would otherwise toggle the lock placeholder on and off. Only surface - // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. - const rawLockReason = inputLockReason ?? null - const lockHeld = useDelayedActivation(rawLockReason !== null) - const lockReason = lockHeld ? rawLockReason : null - // Status liveness belongs to the transport, not the composer's independently delayed lock. - const statusStale = useDelayedActivation(!agentStatusLive) - const canStopAgent = canStopNativeChatAgent({ - targetWritable: stopTargetWritable, + const { lockReason, statusStale, canStopAgent } = useMobileNativeChatControlState({ + inputLockReason: inputLockReason ?? null, + agentStatusLive, + stopTargetWritable, stopCommandAvailable: onStop !== undefined }) @@ -358,22 +352,24 @@ export function MobileNativeChatView({ )} {/* Pending agent prompt: a structured AskUserQuestion wins, then a - heuristic permission, then a heuristic question. */} - {showAsk && ask ? ( + heuristic permission, then a heuristic question. The controller owns + dismissal (it must survive this subtree unmounting on a view toggle); + `ask` arrives already nulled while dismissed. */} + {ask ? ( { const accepted = (await onAnswerAsk?.(ask, selections)) ?? false if (accepted) { - dismissAsk() + onDismissAsk?.() } return accepted }} onCancel={async () => { const accepted = (await onCancelAsk?.()) ?? false if (accepted) { - dismissAsk() + onDismissAsk?.() } return accepted }} @@ -440,6 +436,8 @@ export function MobileNativeChatView({ value={composerText} onChangeText={onComposerTextChange} onSend={handleSend} + agent={agent} + sessionOptions={sessionOptions} onAttachImage={onAttachImage} attachments={attachments} onRemoveAttachment={onRemoveAttachment} diff --git a/mobile/src/session/MobileTerminalLiveInputStatus.tsx b/mobile/src/session/MobileTerminalLiveInputStatus.tsx index dc135c3eb60..a27b09f07d2 100644 --- a/mobile/src/session/MobileTerminalLiveInputStatus.tsx +++ b/mobile/src/session/MobileTerminalLiveInputStatus.tsx @@ -10,11 +10,13 @@ type DictationStatus = { type MobileTerminalLiveInputStatusProps = { readonly dictation: DictationStatus readonly isAttaching: boolean + readonly liveInputText: string } export function MobileTerminalLiveInputStatus({ dictation, - isAttaching + isAttaching, + liveInputText }: MobileTerminalLiveInputStatusProps) { const title = dictation.isRecording ? 'Listening' @@ -31,14 +33,14 @@ export function MobileTerminalLiveInputStatus({ ? 'Preparing microphone' : isAttaching ? 'Uploading image to host' - : 'Tap to show keyboard' + : liveInputText || 'Tap to show keyboard' return ( {title} - + {detail} diff --git a/mobile/src/session/github-pr-rpc.test.ts b/mobile/src/session/github-pr-rpc.test.ts index a287aa4f616..6320d74af0f 100644 --- a/mobile/src/session/github-pr-rpc.test.ts +++ b/mobile/src/session/github-pr-rpc.test.ts @@ -353,15 +353,51 @@ describe('fetch wrappers', () => { }) it('fetchPRForBranch threads linkedPRNumber as authoritative resolver', async () => { - const { client, sendRequest } = mockClient(okResponse({ number: 4, state: 'open' })) + const { client, sendRequest } = mockClient( + okResponse({ + kind: 'found', + pr: { number: 4, state: 'merged' }, + fetchedAt: 1 + }) + ) const out = await fetchPRForBranch(client, WORKTREE_ID, { branch: 'feat', linkedPRNumber: 4 }) expect(out.ok).toBe(true) + expect(out.ok && out.result).toMatchObject({ number: 4, state: 'merged' }) const [method, params] = sendRequest.mock.calls[0]! expect(method).toBe('github.prForBranch') expect(params).toMatchObject({ branch: 'feat', linkedPRNumber: 4 }) expect('prRepo' in (params as object)).toBe(false) }) + it('fetchPRForBranch preserves legacy flat responses', async () => { + const { client } = mockClient(okResponse({ number: 4, state: 'open' })) + const out = await fetchPRForBranch(client, WORKTREE_ID, { branch: 'feat' }) + expect(out.ok && out.result).toMatchObject({ number: 4, state: 'open' }) + }) + + it('fetchPRForBranch maps a classified no-pr response to null', async () => { + const { client } = mockClient(okResponse({ kind: 'no-pr', fetchedAt: 1 })) + await expect(fetchPRForBranch(client, WORKTREE_ID, { branch: 'feat' })).resolves.toEqual({ + ok: true, + result: null + }) + }) + + it('fetchPRForBranch propagates classified upstream errors', async () => { + const { client } = mockClient( + okResponse({ + kind: 'upstream-error', + errorType: 'network', + message: 'network unavailable', + fetchedAt: 1 + }) + ) + await expect(fetchPRForBranch(client, WORKTREE_ID, { branch: 'feat' })).resolves.toEqual({ + ok: false, + error: 'network unavailable' + }) + }) + it('fetchPRChecks forwards headSha + prRepo', async () => { const { client, sendRequest } = mockClient(okResponse([])) await fetchPRChecks(client, WORKTREE_ID, { diff --git a/mobile/src/session/github-pr-rpc.ts b/mobile/src/session/github-pr-rpc.ts index 24baef0a2d1..a2583376662 100644 --- a/mobile/src/session/github-pr-rpc.ts +++ b/mobile/src/session/github-pr-rpc.ts @@ -6,6 +6,10 @@ import type { PRInfo } from '../../../src/shared/types' import type { HostedReviewInfo } from '../../../src/shared/hosted-review' +import { + normalizeGitHubPRForBranchOutcome, + type GitHubPRForBranchResponse +} from '../../../src/shared/github-pr-for-branch-outcome' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import { mobileRepoSelectorFromWorktreeId } from '../source-control/mobile-pr-create' @@ -162,7 +166,20 @@ export async function fetchPRForBranch( branch: args.branch, linkedPRNumber: args.linkedPRNumber ?? null }), - readPRForBranch + (value) => { + const outcome = normalizeGitHubPRForBranchOutcome(value as GitHubPRForBranchResponse) + if (outcome.kind === 'upstream-error') { + throw new Error(outcome.message) + } + if (outcome.kind === 'no-pr') { + return null + } + const pr = readPRForBranch(outcome.pr) + if (!pr) { + throw new Error('GitHub returned an invalid pull request response.') + } + return pr + } ) } diff --git a/mobile/src/session/mobile-file-tap-open.test.ts b/mobile/src/session/mobile-file-tap-open.test.ts index d2a324d2c12..732db327e08 100644 --- a/mobile/src/session/mobile-file-tap-open.test.ts +++ b/mobile/src/session/mobile-file-tap-open.test.ts @@ -64,7 +64,12 @@ describe('openMobileFileTap', () => { expect(client.sendRequest).toHaveBeenCalledWith( 'files.resolveTerminalPath', - { worktree: 'id:wt-1', pathText: '/tmp/result.json', terminal: 'terminal-1' }, + { + worktree: 'id:wt-1', + pathText: '/tmp/result.json', + terminal: 'terminal-1', + crossWorkspace: true + }, { timeoutMs: 10_000 } ) expect(pushPreviewRoute).toHaveBeenCalledWith({ @@ -322,7 +327,13 @@ describe('openMobileFileTap', () => { expect(client.sendRequest).toHaveBeenCalledWith( 'files.resolveTerminalPath', - { worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'term-1', cwd: '/repo/src' }, + { + worktree: 'id:wt-1', + pathText: 'index.ts', + terminal: 'term-1', + cwd: '/repo/src', + crossWorkspace: true + }, { timeoutMs: 10_000 } ) }) diff --git a/mobile/src/session/mobile-file-tap-open.ts b/mobile/src/session/mobile-file-tap-open.ts index 00b98ce214d..627d9a5ff39 100644 --- a/mobile/src/session/mobile-file-tap-open.ts +++ b/mobile/src/session/mobile-file-tap-open.ts @@ -75,6 +75,8 @@ async function openMobileFileTapAsync( { worktree, pathText: options.pathText, + // Why: opts into sibling-workspace resolutions; this caller honors resolved.worktree. + crossWorkspace: true, ...(options.terminalHandle && options.terminalHandle.trim().length > 0 ? { terminal: options.terminalHandle } : {}), diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts index fbdd9eb5dec..35e3cff3b60 100644 --- a/mobile/src/session/mobile-image-source-picker.test.ts +++ b/mobile/src/session/mobile-image-source-picker.test.ts @@ -12,13 +12,28 @@ vi.mock('expo-file-system', () => ({ File: vi.fn() })) -import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker' +import { + ImageLibraryPermissionError, + pickMobileImage, + pickMobileImages, + type PickedMobileImage +} from './mobile-image-source-picker' const granted = { granted: true } as Awaited< ReturnType > const denied = { granted: false } as typeof granted +async function collectImages( + images: AsyncIterable +): Promise { + const collected: PickedMobileImage[] = [] + for await (const image of images) { + collected.push(image) + } + return collected +} + function fileFactory( bytes: Uint8Array, options?: { fileSize?: number; handleSize?: number | null; readError?: Error } @@ -68,6 +83,57 @@ describe('pickMobileImage', () => { expect(file.close).toHaveBeenCalledTimes(1) }) + it('returns every selected library photo in order', async () => { + const bytesByUri = new Map([ + ['file:///a.jpg', new Uint8Array([1])], + ['file:///b.jpg', new Uint8Array([2])], + ['file:///c.jpg', new Uint8Array([3])] + ]) + const createFile = vi.fn((uri: string) => { + const bytes = bytesByUri.get(uri)! + let read = false + return { + size: bytes.length, + open: () => ({ + size: bytes.length, + readBytes: () => { + if (read) { + return new Uint8Array() + } + read = true + return bytes + }, + close: vi.fn() + }) + } + }) + const launchLibrary = vi.fn().mockResolvedValue({ + canceled: false, + assets: [...bytesByUri].map(([uri, bytes]) => ({ uri, fileSize: bytes.length })) + }) + + const result = await collectImages( + pickMobileImages('library', { + requestLibraryPermission: vi.fn().mockResolvedValue(granted), + launchLibrary, + createFile + }) + ) + + expect(result.map((image) => image.uri)).toEqual([ + 'file:///a.jpg', + 'file:///b.jpg', + 'file:///c.jpg' + ]) + expect(launchLibrary).toHaveBeenCalledWith( + expect.objectContaining({ + allowsMultipleSelection: true, + orderedSelection: true, + selectionLimit: 0 + }) + ) + }) + it('throws when photo library permission is denied', async () => { await expect( pickMobileImage('library', { diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index bf9c529cc25..062085f7270 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -83,11 +83,12 @@ async function readUriAsBase64( } } -async function pickFromLibrary( +async function* pickFromLibrary( + multiple: boolean, requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync, launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const permission = await requestPermission() // Why: `granted` covers full + limited iOS access; only a hard denial blocks us. if (!permission.granted) { @@ -96,51 +97,84 @@ async function pickFromLibrary( const result = await launch({ mediaTypes: ['images'], base64: false, - allowsMultipleSelection: false, + allowsMultipleSelection: multiple, + ...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}), quality: 1 }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null - if (!base64) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.fileSize, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) } } -async function pickFromFiles( +async function* pickFromFiles( + multiple: boolean, launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const result = await launch({ type: 'image/*', - multiple: false, + multiple, copyToCacheDirectory: true }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - if (!asset?.uri) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) - return base64 ? { base64, uri: asset.uri } : null +} + +type MobileImagePickerDeps = { + readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync + readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync + readonly launchFiles?: typeof DocumentPicker.getDocumentAsync + readonly createFile?: MobileImageFileFactory +} + +function pickMobileImagesWithMode( + source: MobileImageSource, + multiple: boolean, + deps?: MobileImagePickerDeps +): AsyncIterable { + if (source === 'library') { + return pickFromLibrary( + multiple, + deps?.requestLibraryPermission, + deps?.launchLibrary, + deps?.createFile + ) + } + return pickFromFiles(multiple, deps?.launchFiles, deps?.createFile) } export async function pickMobileImage( source: MobileImageSource, - deps?: { - readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync - readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync - readonly launchFiles?: typeof DocumentPicker.getDocumentAsync - readonly createFile?: MobileImageFileFactory - } + deps?: MobileImagePickerDeps ): Promise { - if (source === 'library') { - return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile) + for await (const image of pickMobileImagesWithMode(source, false, deps)) { + return image } - return pickFromFiles(deps?.launchFiles, deps?.createFile) + return null +} + +export function pickMobileImages( + source: MobileImageSource, + deps?: MobileImagePickerDeps +): AsyncIterable { + return pickMobileImagesWithMode(source, true, deps) } diff --git a/mobile/src/session/mobile-native-chat-autocomplete.test.ts b/mobile/src/session/mobile-native-chat-autocomplete.test.ts index 25c904c7b13..608917449d4 100644 --- a/mobile/src/session/mobile-native-chat-autocomplete.test.ts +++ b/mobile/src/session/mobile-native-chat-autocomplete.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { applyAutocomplete, detectAutocompleteTrigger, + rankSlashCommandSuggestions, rankSuggestions } from './mobile-native-chat-autocomplete' @@ -57,3 +58,36 @@ describe('rankSuggestions', () => { expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b']) }) }) + +describe('rankSlashCommandSuggestions', () => { + const commands = [ + { name: 'clear', description: 'Clear conversation history' }, + { name: 'compact', description: 'Summarize and compact' }, + { name: 'mcp', description: 'List MCP tools' } + ] + + it('shows the whole catalog for a bare slash', () => { + expect(rankSlashCommandSuggestions(commands, '').map((c) => c.name)).toEqual([ + 'clear', + 'compact', + 'mcp' + ]) + }) + + it('ranks prefix matches ahead of substring matches', () => { + expect(rankSlashCommandSuggestions(commands, 'c').map((c) => c.name)).toEqual([ + 'clear', + 'compact', + 'mcp' + ]) + expect(rankSlashCommandSuggestions(commands, 'm').map((c) => c.name)).toEqual([ + 'mcp', + 'compact' + ]) + }) + + it('is case-insensitive and drops non-matches', () => { + expect(rankSlashCommandSuggestions(commands, 'CLE').map((c) => c.name)).toEqual(['clear']) + expect(rankSlashCommandSuggestions(commands, 'zzz')).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-autocomplete.ts b/mobile/src/session/mobile-native-chat-autocomplete.ts index de0ddc91f67..548d0614837 100644 --- a/mobile/src/session/mobile-native-chat-autocomplete.ts +++ b/mobile/src/session/mobile-native-chat-autocomplete.ts @@ -2,6 +2,8 @@ // `/slash` commands. Detection is pure (text + cursor → active trigger) so it's // unit-testable and the composer stays a thin view over it. +import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands' + export type AutocompleteKind = 'file' | 'slash' export type AutocompleteTrigger = { @@ -91,3 +93,31 @@ export function rankSuggestions(candidates: readonly string[], query: string, li } return [...prefix, ...substring].slice(0, limit) } + +/** Rank an agent's slash commands: prefix matches first, then substring, in + * catalog order. A bare `/` shows the whole catalog (desktop parity) — the + * suggestion list scrolls, so the cap only guards against absurd catalogs. */ +export function rankSlashCommandSuggestions( + commands: readonly SlashCommandSuggestion[], + query: string, + limit = 50 +): SlashCommandSuggestion[] { + const q = query.toLowerCase() + if (q.length === 0) { + return commands.slice(0, limit) + } + const prefix: SlashCommandSuggestion[] = [] + const substring: SlashCommandSuggestion[] = [] + for (const command of commands) { + const lower = command.name.toLowerCase() + if (lower.startsWith(q)) { + prefix.push(command) + } else if (lower.includes(q)) { + substring.push(command) + } + if (prefix.length >= limit) { + break + } + } + return [...prefix, ...substring].slice(0, limit) +} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts new file mode 100644 index 00000000000..433fba42da1 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -0,0 +1,68 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { detectAgentPermission } from './mobile-native-chat-permission' +import type { parseAgentQuestion } from './mobile-native-chat-question' +import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' +import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' + +export type MobileNativeChatController = { + /** Whether a tab's effective view is chat (per-tab override, else the default). */ + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: MobileNativeChatPendingMessage[] + chatImagePreviewsByMessageId: Record + nativeChatSession: ReturnType + nativeChatAgentWorking: boolean + /** Whether transport state can still refresh the displayed agent status. */ + nativeChatAgentStatusLive: boolean + /** Exact write gate used by the Stop handler. */ + nativeChatStopTargetWritable: boolean + nativeChatStreamingText?: string + /** Agent mid-turn, regardless of whether chat is the visible view. */ + nativeChatStreamLive: boolean + /** Host/workspace/tab/session scope for stateful streaming suppression. */ + nativeChatStreamScopeKey: string + nativeChatPermission: ReturnType + nativeChatQuestion: ReturnType + /** The pending ask, already null while dismissed (dismissal lives here so it + * survives the chat-view subtree unmounting on a view toggle). */ + nativeChatAsk: ReturnType + /** Stable key for the current ask card (keys the card component). */ + nativeChatAskKey: string | null + /** Hide the current ask until a genuinely different question arrives. */ + dismissNativeChatAsk: () => void + handleNativeChatAnswerAsk: ( + prompt: AskPrompt, + selections: AskAnswerSelection[] + ) => Promise + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatQuestionAnswer: (text: string) => Promise + handleNativeChatSend: (text: string, images?: string[]) => Promise + /** Outcome-preserving send: callers that pasted terminal input beforehand + * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a + * caller passes its own `deadline` so the paste it already spent and this text + * body share one budget instead of holding the composer for two. */ + handleNativeChatSendWithOutcome: ( + text: string, + images?: string[], + deadline?: number + ) => Promise + /** Launch-context text still parked on the agent's TUI input line, or null. + * Image sends read it to size their leading clear (one Ctrl+U per line). */ + readSeededLaunchDraft: () => string | null + /** Model/session-option pickers for the composer, or null when the active + * agent has no session-option catalog. */ + nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null +} diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts new file mode 100644 index 00000000000..4c0cee804c1 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + findLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, + type PendingImagePreviewEcho +} from './mobile-native-chat-draft-reconcile' + +function userText(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function pending(id: string, images: string[], expectedOccurrence = 1): PendingImagePreviewEcho { + return { id, text: '', images, expectedOccurrence, baselineTailMessageId: null } +} + +describe('mobile native chat image preview reconciliation', () => { + it('keeps separate adjacent image-only sends independently reconcilable', () => { + const landed = findLandedImagePreviewEchoes( + [ + userText('source-a', '[Image: source: /tmp/a.png]'), + userText('source-b', '[Image: source: /tmp/b.png]') + ], + [pending('pending-a', ['file:///a.jpg']), pending('pending-b', ['file:///b.jpg'], 2)] + ) + + expect(landed).toEqual([ + { pendingId: 'pending-a', messageId: 'source-a', images: ['file:///a.jpg'] }, + { pendingId: 'pending-b', messageId: 'source-b', images: ['file:///b.jpg'] } + ]) + }) + + it('waits for a complete multi-image turn as transcript source frames stream in', () => { + const entry = pending('pending', ['file:///a.jpg', 'file:///b.jpg']) + const sourceA = userText('source-a', '[Image: source: /tmp/a.png]') + const sourceB = userText('source-b', '[Image: source: /tmp/b.png]') + + expect(findLandedImagePreviewEchoes([sourceA], [entry])).toEqual([]) + expect(findLandedImagePreviewEchoes([sourceA, sourceB], [entry])).toEqual([]) + expect( + findLandedImagePreviewEchoes( + [sourceA, sourceB, userText('prompt', '[Image #1] [Image #2]')], + [entry] + ) + ).toEqual([ + { + pendingId: 'pending', + messageId: 'prompt', + images: ['file:///a.jpg', 'file:///b.jpg'] + } + ]) + }) + + it('moves an early standalone preview to the later folded prompt id', () => { + const sessionKey = 'host\0worktree\0tab\0session' + const previous = { [sessionKey]: { source: ['file:///a.jpg'] } } + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', '[Image #1]') + ] + + expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({ + [sessionKey]: { prompt: ['file:///a.jpg'] } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 1af010ff503..360fd22587b 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -1,6 +1,7 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { isImageSourceUserTurn, + normalizeImageTranscriptMessages, stripImagePromptMarker } from './mobile-native-chat-image-transcript-markers' @@ -61,6 +62,149 @@ export function countImageSourceTurnsAfter( return count } +export type PendingImagePreviewEcho = { + id: string + text: string + images?: string[] + expectedOccurrence: number + baselineTailMessageId: string | null +} + +export type LandedImagePreviewEcho = { + pendingId: string + messageId: string + images: string[] +} + +const SENT_IMAGE_PREVIEW_LIMIT = 32 +const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8 + +export function mergeLandedImagePreviewEchoes( + previous: Record>, + sessionKey: string, + landed: readonly LandedImagePreviewEcho[] +): Record> { + const entries = Object.entries(previous[sessionKey] ?? {}) + for (const preview of landed) { + const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) + if (existingIndex >= 0) { + entries.splice(existingIndex, 1) + } + entries.push([preview.messageId, preview.images]) + } + const next = { ...previous } + delete next[sessionKey] + next[sessionKey] = Object.fromEntries(entries.slice(-SENT_IMAGE_PREVIEW_LIMIT)) + for (const key of Object.keys(next).slice(0, -SENT_IMAGE_PREVIEW_SESSION_LIMIT)) { + delete next[key] + } + return next +} + +function imagePreviewReplacementMessageId( + messages: readonly NativeChatMessage[], + sourceIndex: number +): string | null { + const source = messages[sourceIndex] + if (!source || !isImageSourceUserTurn(source)) { + return null + } + let nextIndex = sourceIndex + 1 + while ( + messages[nextIndex]?.source === source.source && + isImageSourceUserTurn(messages[nextIndex]!) + ) { + nextIndex++ + } + const prompt = messages[nextIndex] + const firstText = prompt?.blocks.find((block) => block.type === 'text') + return prompt?.role === 'user' && + prompt.source === source.source && + firstText?.type === 'text' && + stripImagePromptMarker(firstText.text) !== firstText.text + ? prompt.id + : null +} + +/** Moves previews forward when a progressive source-only transcript frame later + * folds into the marker-prefixed prompt with a different authoritative id. */ +export function migrateImagePreviewMessageIds( + previous: Record>, + sessionKey: string, + messages: readonly NativeChatMessage[] +): Record> { + const sessionPreviews = previous[sessionKey] + if (!sessionPreviews) { + return previous + } + const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) + let nextSession: Record | null = null + for (const [messageId, images] of Object.entries(sessionPreviews)) { + const sourceIndex = messageIndexById.get(messageId) + if (sourceIndex === undefined) { + continue + } + const replacementId = imagePreviewReplacementMessageId(messages, sourceIndex) + if (!replacementId) { + continue + } + nextSession ??= { ...sessionPreviews } + delete nextSession[messageId] + nextSession[replacementId] = [...(nextSession[replacementId] ?? []), ...images] + } + return nextSession ? { ...previous, [sessionKey]: nextSession } : previous +} + +/** Binds local preview URIs to the authoritative transcript turn that replaced + * the optimistic bubble. Host paths and marker-only Codex turns cannot render + * the phone-local photo without this handoff. */ +export function findLandedImagePreviewEchoes( + messages: readonly NativeChatMessage[], + entries: readonly PendingImagePreviewEcho[] +): LandedImagePreviewEcho[] { + const normalized = normalizeImageTranscriptMessages(messages) + const messageIndexById = new Map(normalized.map((message, index) => [message.id, index])) + const claimedMessageIds = new Set() + const landed: LandedImagePreviewEcho[] = [] + + for (const entry of entries) { + if (!entry.images?.length) { + continue + } + const targetText = entry.text.trim() + const candidates = normalized.filter((message) => { + if (message.role !== 'user') { + return false + } + if (targetText) { + return normalizedUserText(message) === targetText + } + const imageCount = message.blocks.filter(isImageRefBlock).length + return message.blocks.length === 0 || imageCount >= entry.images!.length + }) + const tailIndex = entry.baselineTailMessageId + ? messageIndexById.get(entry.baselineTailMessageId) + : -1 + const occurrenceIndex = Math.max(0, entry.expectedOccurrence - 1) + const candidate = targetText + ? candidates[occurrenceIndex] + : candidates.filter( + (message) => + tailIndex === undefined || (messageIndexById.get(message.id) ?? -1) > tailIndex + )[occurrenceIndex] + if ( + !candidate || + claimedMessageIds.has(candidate.id) || + (tailIndex !== undefined && (messageIndexById.get(candidate.id) ?? -1) <= tailIndex) + ) { + continue + } + claimedMessageIds.add(candidate.id) + landed.push({ pendingId: entry.id, messageId: candidate.id, images: entry.images }) + } + return landed +} + export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], entries: readonly UnconfirmedSend[] diff --git a/mobile/src/session/mobile-native-chat-image-attachment.test.ts b/mobile/src/session/mobile-native-chat-image-attachment.test.ts index 57c3f67bd1c..e61116f3caa 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.test.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' -import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment' +import { uploadMobileNativeChatImages } from './mobile-native-chat-image-attachment' function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } @@ -16,6 +16,10 @@ function methodNotFound(id: string): RpcResponse { } } +function failed(id: string, message: string): RpcResponse { + return { id, ok: false, error: { code: 'failed', message }, _meta: { runtimeId: 'r' } } +} + function clientWithResponses(responses: RpcResponse[]): Pick & { calls: { method: string; params: unknown }[] } { @@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick { +describe('uploadMobileNativeChatImages', () => { it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => { const client = clientWithResponses([ methodNotFound('start'), ok('save', '/tmp/orca-attach.png') ]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => 'conn-7', - pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'AAAA', uri: 'file:///photo.jpg' }]) }) - expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }) + expect(result).toEqual([{ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }]) // Native chat defers the paste to submit — nothing is sent to the terminal here. expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile') expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' }) }) + it('uploads all three selected images in picker order', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + ok('save-b', '/tmp/b.png'), + methodNotFound('start-c'), + ok('save-c', '/tmp/c.png') + ]) + + const order: string[] = [] + async function* pickImages() { + for (const image of [ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' }, + { base64: 'CCCC', uri: 'file:///c.jpg' } + ]) { + order.push(`read:${image.uri}`) + yield image + } + } + const result = await uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => 'conn-7', + pickImages, + onImageUploaded: (image) => order.push(`uploaded:${image.previewUri}`) + }) + + expect(result).toEqual([ + { path: '/tmp/a.png', previewUri: 'file:///a.jpg' }, + { path: '/tmp/b.png', previewUri: 'file:///b.jpg' }, + { path: '/tmp/c.png', previewUri: 'file:///c.jpg' } + ]) + expect(order).toEqual([ + 'read:file:///a.jpg', + 'uploaded:file:///a.jpg', + 'read:file:///b.jpg', + 'uploaded:file:///b.jpg', + 'read:file:///c.jpg', + 'uploaded:file:///c.jpg' + ]) + }) + it('returns null when the picker is cancelled and uploads nothing', async () => { const client = clientWithResponses([]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null) + pickImages: vi.fn().mockResolvedValue([]) }) - expect(result).toBeNull() + expect(result).toEqual([]) expect(client.calls).toEqual([]) }) + it('reports completed uploads before a later image fails', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + failed('save-b', 'upload failed') + ]) + const onImageUploaded = vi.fn() + + await expect( + uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => null, + pickImages: vi.fn().mockResolvedValue([ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' } + ]), + onImageUploaded + }) + ).rejects.toThrow('upload failed') + expect(onImageUploaded).toHaveBeenCalledOnce() + expect(onImageUploaded).toHaveBeenCalledWith({ + path: '/tmp/a.png', + previewUri: 'file:///a.jpg' + }) + }) + it('falls back to an inline data uri for the preview when the picker omits a uri', async () => { const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')]) - const result = await uploadMobileNativeChatImage('files', { + const result = await uploadMobileNativeChatImages('files', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'BBBB' }]) }) - expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }) + expect(result).toEqual([{ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }]) }) it('signals upload start only after a real image is picked', async () => { const onUploadStart = vi.fn() const cancelledClient = clientWithResponses([]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client: cancelledClient, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null), + pickImages: vi.fn().mockResolvedValue([]), onUploadStart }) expect(onUploadStart).not.toHaveBeenCalled() const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }), + pickImages: vi.fn().mockResolvedValue([{ base64: 'CCCC', uri: 'file:///y.jpg' }]), onUploadStart }) expect(onUploadStart).toHaveBeenCalledTimes(1) diff --git a/mobile/src/session/mobile-native-chat-image-attachment.ts b/mobile/src/session/mobile-native-chat-image-attachment.ts index f81b9d2ad98..fc9a962bacf 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.ts @@ -13,34 +13,67 @@ export type PendingNativeChatImage = { readonly previewUri: string } -export type UploadNativeChatImageDeps = { +export function appendPendingNativeChatImages( + current: readonly PendingNativeChatImage[], + uploaded: readonly Omit[], + idCounter: { current: number } +): PendingNativeChatImage[] { + return [ + ...current, + ...uploaded.map((image) => { + idCounter.current += 1 + return { id: `img-${idCounter.current}`, ...image } + }) + ] +} + +export type UploadNativeChatImagesDeps = { readonly client: Pick readonly getConnectionId: () => Promise // Injected so this module stays free of expo/react-native imports (unit-testable). - readonly pickImage: (source: MobileImageSource) => Promise + readonly pickImages: ( + source: MobileImageSource + ) => + | Iterable + | AsyncIterable + | Promise | AsyncIterable> // Fired once the user has picked an image and the host upload is about to start — // lets the UI show the attach spinner only for the transfer, not the picker. readonly onUploadStart?: () => void + /** Retains each completed upload if a later image in the same selection fails. */ + readonly onImageUploaded?: (image: Omit) => void } /** Picks an image and uploads it to the host, returning the host path + a local * preview URI — but does NOT paste it into the terminal. Unlike the terminal * attach flow, native chat holds the image as a composer chip and rides it along * on submit (desktop parity), so the chip and the agent input never diverge. - * Returns null when the user cancels the picker. */ -export async function uploadMobileNativeChatImage( + * Returns an empty array when the user cancels the picker. */ +export async function uploadMobileNativeChatImages( source: MobileImageSource, - { client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps -): Promise | null> { - const picked = await pickImage(source) - if (!picked) { - return null + { + client, + getConnectionId, + pickImages, + onUploadStart, + onImageUploaded + }: UploadNativeChatImagesDeps +): Promise[]> { + const picked = await pickImages(source) + const uploaded: Omit[] = [] + let connectionId: string | null = null + for await (const image of picked) { + if (uploaded.length === 0) { + onUploadStart?.() + connectionId = await getConnectionId() + } + const path = await saveMobileClipboardImageAsTempFile(client, image.base64, { connectionId }) + // Prefer the picker's local URI for the thumbnail; fall back to an inline data + // URI when the source omitted one (RN renders both). + const previewUri = image.uri ?? `data:image/png;base64,${image.base64}` + const result = { path, previewUri } + uploaded.push(result) + onImageUploaded?.(result) } - onUploadStart?.() - const connectionId = await getConnectionId() - const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId }) - // Prefer the picker's local URI for the thumbnail; fall back to an inline data - // URI when the source omitted one (RN renders both). - const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}` - return { path, previewUri } + return uploaded } diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index 46ac4730f2e..cf1c59adf0f 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick { it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => { - const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)]) + const client = clientWithResponses([ + sendResult(true), + sendResult(true), + sendResult(true), + sendResult(true) + ]) const ok = await pasteMobileNativeChatImagePaths({ client, terminal: 'term-1', deviceToken: 'device-9', - imagePaths: ['/tmp/a.png', '/tmp/b.png'] + imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'] }) expect(ok).toBe(true) - expect(client.calls).toHaveLength(3) + expect(client.calls).toHaveLength(4) // Leading Ctrl+U clears any stale input so a retry can't duplicate the image. expect(client.calls[0]).toEqual({ method: 'terminal.send', @@ -50,6 +55,7 @@ describe('pasteMobileNativeChatImagePaths', () => { }) expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~') expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~') + expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~') }) it('stops and reports failure as soon as a paste is rejected', async () => { diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 41d77564fd2..df1bad92815 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -3,7 +3,6 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme' export const TEXT_SIZE = 17 export const MONO_SIZE = 12 -export const MAX_TOOL_RESULT_CHARS = 4000 export const styles = StyleSheet.create({ row: { diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts index c79878e92d1..d44530e5cd0 100644 --- a/mobile/src/session/mobile-native-chat-open-file.test.ts +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -60,7 +60,7 @@ describe('openMobileNativeChatFileTap', () => { expect(sendRequest).toHaveBeenCalledWith( 'files.resolveTerminalPath', - { worktree: 'id:wt-1', pathText: 'src/app.ts' }, + { worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true }, { timeoutMs: 10_000 } ) }) @@ -74,7 +74,7 @@ describe('openMobileNativeChatFileTap', () => { expect(sendRequest).toHaveBeenCalledWith( 'files.resolveTerminalPath', - { worktree: 'id:wt-1', pathText: 'src/app.ts' }, + { worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true }, { timeoutMs: 10_000 } ) expect(options.triggerOpenFeedback).toHaveBeenCalledTimes(1) diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts new file mode 100644 index 00000000000..b18136a9d47 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -0,0 +1,91 @@ +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number + /** Local preview URIs carried by the send for its optimistic echo. */ + images?: string[] + baselineTailMessageId: string | null +} + +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number + baselineTailMessageId: string | null +} + +type PendingByKey = Record + +export function combineMobileNativeChatPending( + session: MobileNativeChatPendingMessage[], + waiting: readonly MobileNativeChatPendingMessage[] +): MobileNativeChatPendingMessage[] { + if (waiting.length === 0) { + return session + } + const sessionIds = new Set(session.map((item) => item.id)) + return [...session, ...waiting.filter((item) => !sessionIds.has(item.id))] +} + +export function appendMobileNativeChatPending( + previous: PendingByKey, + key: string, + id: string, + origin: MobileNativeChatSendOrigin, + text: string, + images?: string[] +): PendingByKey { + const current = previous[key] ?? [] + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const expectedImageEchoOrdinal = + current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1 + return { + ...previous, + [key]: [ + ...current, + { + id, + text, + expectedOccurrence: + origin.normalizedText === '' + ? expectedImageEchoOrdinal + : origin.baselineOccurrences + earlierOutstanding + 1, + baselineTailMessageId: origin.baselineTailMessageId, + ...(images?.length ? { images } : {}) + } + ] + } +} + +export function mergeWaitingSessionPending( + previous: PendingByKey, + sessionKey: string, + waiting: readonly MobileNativeChatPendingMessage[] +): PendingByKey { + const current = previous[sessionKey] ?? [] + const currentIds = new Set(current.map((item) => item.id)) + const moved = waiting.filter((item) => !currentIds.has(item.id)) + return moved.length > 0 ? { ...previous, [sessionKey]: [...current, ...moved] } : previous +} + +export function removeWaitingSessionPending( + previous: PendingByKey, + draftKey: string, + movedIds: ReadonlySet +): PendingByKey { + const remaining = (previous[draftKey] ?? []).filter((item) => !movedIds.has(item.id)) + if (remaining.length > 0) { + return { ...previous, [draftKey]: remaining } + } + if (!(draftKey in previous)) { + return previous + } + const next = { ...previous } + delete next[draftKey] + return next +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index b4a273d31ca..c67257ec24d 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -116,6 +116,36 @@ describe('buildMobileNativeChatTransientData', () => { expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) }) + it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => { + const folded = foldMobileNativeChatMessages([ + user('source', '[Image: source: /tmp/a.png]'), + user('prompt', '[Image #1] look at this') + ]) + const result = buildMobileNativeChatTransientData({ + folded, + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data).toHaveLength(1) + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' }, + { type: 'text', text: 'look at this' } + ]) + }) + + it('restores the local preview onto a marker-only transcript turn', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }]) + }) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { // Whether text streams at all is the gate's call // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index e11b9ac56f4..b3d4ba1c0fd 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,7 +3,7 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' import { stripNoiseMessages } from './mobile-native-chat-noise' @@ -55,15 +55,37 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat export function buildMobileNativeChatTransientData({ folded, streaming, - pending + pending, + imagePreviewsByMessageId }: { folded: NativeChatMessage[] /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ streaming: string | null pending: MobileNativeChatPendingItem[] + imagePreviewsByMessageId?: Record }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const renderedFolded = folded.map((message) => { + const previews = imagePreviewsByMessageId?.[message.id] + if (message.role !== 'user' || !previews?.length) { + return message + } + let previewIndex = 0 + const blocks = message.blocks.map((block) => { + if (!isImageRefBlock(block)) { + return block + } + const url = previews[previewIndex] + previewIndex += 1 + return url ? { ...block, url } : block + }) + while (previewIndex < previews.length) { + blocks.push({ type: 'image-ref', url: previews[previewIndex] }) + previewIndex += 1 + } + return { ...message, blocks } + }) const data: NativeChatMessage[] = [ - ...folded, + ...renderedFolded, ...(streaming ? [ { @@ -88,5 +110,5 @@ export function buildMobileNativeChatTransientData({ source: 'transcript' as const })) ] - return { folded, streaming, data } + return { folded: renderedFolded, streaming, data } } diff --git a/mobile/src/session/mobile-native-chat-send-classification.test.ts b/mobile/src/session/mobile-native-chat-send-classification.test.ts new file mode 100644 index 00000000000..55b9863efe9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send-classification.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' + +describe('classifyMobileNativeChatSend', () => { + it('classifies catalog commands per agent', () => { + expect(classifyMobileNativeChatSend('claude', '/clear')).toBe('command') + expect(classifyMobileNativeChatSend('claude', '/compact')).toBe('command') + expect(classifyMobileNativeChatSend('codex', '/model')).toBe('command') + expect(classifyMobileNativeChatSend('codex', '/permissions')).toBe('command') + }) + + it('treats slash tokens outside the agent catalog as unknown, never chat', () => { + // `/model` is not a verified Claude command — it still dispatches to the + // TUI, so it must not get a chat bubble, but it can't claim a command ran. + expect(classifyMobileNativeChatSend('claude', '/model sonnet')).toBe('unknown-token') + expect(classifyMobileNativeChatSend('claude', '/cost')).toBe('unknown-token') + expect(classifyMobileNativeChatSend('claude', '/diff')).toBe('unknown-token') + }) + + it('keeps prose as chat, including leading-whitespace slash text', () => { + expect(classifyMobileNativeChatSend('claude', 'hello there')).toBe('chat') + expect(classifyMobileNativeChatSend('claude', ' /clear is a command')).toBe('chat') + expect(classifyMobileNativeChatSend('claude', '/usr/bin/python is missing')).toBe( + 'unknown-token' + ) + }) + + it('treats $ tokens as skill grammar only for Codex', () => { + expect(classifyMobileNativeChatSend('codex', '$deploy now')).toBe('unknown-token') + expect(classifyMobileNativeChatSend('claude', '$PATH is empty')).toBe('chat') + }) + + it('defaults to chat when no agent is resolved', () => { + expect(classifyMobileNativeChatSend(null, '/clear')).toBe('chat') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-send-classification.ts b/mobile/src/session/mobile-native-chat-send-classification.ts new file mode 100644 index 00000000000..18568c05789 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send-classification.ts @@ -0,0 +1,34 @@ +// Mobile counterpart of desktop's send classification gate +// (src/renderer/src/components/native-chat/NativeChatComposer.tsx): slash/skill +// sends are TUI control actions, not chat turns — they never echo as a user +// bubble, because the transcript will never contain a matching user turn and +// the optimistic echo would sit at "Queued" forever. + +import { + getNativeChatAgentProfile, + getVerifiedNativeChatCommands +} from '../../../src/shared/native-chat-agent-profiles' +import { + classifyNativeChatSend, + type NativeChatSendClassification +} from '../../../src/shared/native-chat-slash-commands' + +export type { NativeChatSendClassification } + +/** Classify a mobile chat send for the tab's agent. Mobile has no skill picker, + * so there is never a picker-origin token that reclassifies a `/token` as chat. */ +export function classifyMobileNativeChatSend( + agent: string | null, + text: string +): NativeChatSendClassification { + if (!agent) { + return 'chat' + } + const profile = getNativeChatAgentProfile(agent) + return classifyNativeChatSend( + text, + getVerifiedNativeChatCommands(agent), + null, + profile?.skillPrefix ?? null + ) +} diff --git a/mobile/src/session/mobile-native-chat-session-option-labels.ts b/mobile/src/session/mobile-native-chat-session-option-labels.ts new file mode 100644 index 00000000000..85d1cc3ea49 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-session-option-labels.ts @@ -0,0 +1,79 @@ +// Mobile port of desktop's pill labeling +// (src/renderer/src/components/native-chat/native-chat-session-option-labels.ts), +// minus i18n — mobile renders plain strings throughout. + +import type { + SessionOptionDescriptor, + SessionOptionDisabledReason, + SessionOptionSelectChoice +} from '../../../src/shared/native-chat-session-options' + +export function mobileSessionOptionDisabledReason( + reason: SessionOptionDisabledReason | undefined +): string | null { + // Exhaustive over SessionOptionDisabledReason so new keys are a compile error. + switch (reason) { + case 'set-when-session-starts': + return 'Set when the session starts.' + case 'available-after-session-start': + return 'Available after the session starts.' + case undefined: + return null + } +} + +function selectedChoiceLabel(descriptor: SessionOptionDescriptor): string | null { + if ( + descriptor.valueSource === 'unknown' || + descriptor.kind.type !== 'select' || + !descriptor.kind.currentValue + ) { + return null + } + const current = descriptor.kind.currentValue + const choice: SessionOptionSelectChoice = descriptor.kind.choices.find( + (candidate) => candidate.value === current + ) ?? { value: current, label: current } + return choice.label +} + +/** Value-only pill text — the category lives on the sheet title, not the pill. */ +export function mobileModelPillLabel(descriptor: SessionOptionDescriptor): string { + return selectedChoiceLabel(descriptor) ?? 'Model' +} + +export function mobileSessionOptionSummaryValue(descriptor: SessionOptionDescriptor): string { + if (descriptor.valueSource === 'unknown') { + return 'Not set' + } + if (descriptor.kind.type === 'select') { + return selectedChoiceLabel(descriptor) ?? 'Not set' + } + return descriptor.kind.currentValue === undefined + ? 'Not set' + : descriptor.kind.currentValue + ? 'On' + : 'Off' +} + +export function mobileOptionsPillLabel(descriptors: readonly SessionOptionDescriptor[]): string { + const labels: string[] = [] + for (const descriptor of descriptors) { + if (descriptor.valueSource === 'unknown') { + continue + } + if (descriptor.kind.type === 'select') { + const label = selectedChoiceLabel(descriptor) + if (label) { + labels.push(label) + } + } else if (descriptor.kind.currentValue === true) { + labels.push(descriptor.id === 'fastMode' ? 'Fast' : descriptor.label) + } + } + if (labels.length > 0) { + return labels.join(' · ') + } + const effort = descriptors.find((descriptor) => descriptor.id === 'effort') + return effort ? effort.label : 'Options' +} diff --git a/mobile/src/session/mobile-native-chat-tool-summary.test.ts b/mobile/src/session/mobile-native-chat-tool-summary.test.ts index d08deec4b31..8e65ccf1958 100644 --- a/mobile/src/session/mobile-native-chat-tool-summary.test.ts +++ b/mobile/src/session/mobile-native-chat-tool-summary.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary' +import { + briefToolArg, + describeToolInput, + summarizeToolInput, + toolFilePath +} from './mobile-native-chat-tool-summary' describe('summarizeToolInput', () => { it('passes short strings through, collapsing whitespace', () => { @@ -53,6 +58,13 @@ describe('toolFilePath', () => { }) }) +describe('describeToolInput', () => { + it('is re-exported and labels rows with the path or primary argument', () => { + expect(describeToolInput({ file_path: 'src/a.ts', offset: 3 })).toBe('src/a.ts') + expect(describeToolInput('{"cmd":"git status"}')).toBe('git status') + }) +}) + describe('briefToolArg', () => { it('takes the basename of a forward-slash path', () => { expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts') diff --git a/mobile/src/session/mobile-native-chat-tool-summary.ts b/mobile/src/session/mobile-native-chat-tool-summary.ts index bc3f7a2f89a..0ee6576d767 100644 --- a/mobile/src/session/mobile-native-chat-tool-summary.ts +++ b/mobile/src/session/mobile-native-chat-tool-summary.ts @@ -1,7 +1,13 @@ export { briefToolArg, countToolCalls, + createToolInputDisplay, + describeToolInput, + formatToolInput, + isStructuredToolInput, + MAX_TOOL_DETAIL_LENGTH, summarizeToolInput, summarizeToolRun, - toolFilePath + toolFilePath, + truncateToolDetail } from '../../../src/shared/native-chat-tool-summary' diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index e97af9df383..d9126475bc3 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -81,7 +81,7 @@ describe('mobile session route', () => { name: 'Fix #1' }) - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', target) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -106,7 +106,19 @@ describe('mobile session route', () => { expect(end).toBeGreaterThan(start) const resumeCard = homeSource.slice(start, end) - expect(resumeCard).toContain('openMobileSession({') + expect(resumeCard).toContain('openResume(') expect(resumeCard).not.toContain('router.push(') + + // The tap handler itself must go through the coordinated transition; its only + // direct push is the shallow noticed host-index route for a proven-missing target. + const handlerStart = homeSource.indexOf('const openResume = useCallback(') + const handlerEnd = homeSource.indexOf('[openMobileSession, router]', handlerStart) + expect(handlerStart).toBeGreaterThanOrEqual(0) + expect(handlerEnd).toBeGreaterThan(handlerStart) + + const openResume = homeSource.slice(handlerStart, handlerEnd) + expect(openResume).toContain('openMobileSession({') + expect(openResume.match(/router\.push\(/g)).toHaveLength(1) + expect(openResume).toContain('router.push(hostRouteWithNotice(') }) }) diff --git a/mobile/src/session/mobile-session-tab-activation.test.ts b/mobile/src/session/mobile-session-tab-activation.test.ts index 5e7503f24d4..8c490dffca5 100644 --- a/mobile/src/session/mobile-session-tab-activation.test.ts +++ b/mobile/src/session/mobile-session-tab-activation.test.ts @@ -45,7 +45,8 @@ describe('mobile session tab activation', () => { tabId: 'tab-1', leafId: 'leaf-1', notifyClients: false as const, - navigation: 'caller' as const + navigation: 'caller' as const, + intent: 'user' as const } await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({ diff --git a/mobile/src/session/mobile-session-tab-activation.ts b/mobile/src/session/mobile-session-tab-activation.ts index 8a8bd80fc96..ff8353459b4 100644 --- a/mobile/src/session/mobile-session-tab-activation.ts +++ b/mobile/src/session/mobile-session-tab-activation.ts @@ -1,3 +1,4 @@ +import type { TabActivationIntent } from '../../../src/shared/tab-activation-intent' import type { RpcClient } from '../transport/rpc-client' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { RpcResponse } from '../transport/types' @@ -15,6 +16,8 @@ type MobileSessionTabActivationParams = { leafId?: string notifyClients: false navigation: 'caller' + /** Required so each call site declares whether a user asked for this. */ + intent: TabActivationIntent } async function retryIdempotentActivationAfterCutover( diff --git a/mobile/src/session/synthetic-workspace-route.ts b/mobile/src/session/synthetic-workspace-route.ts new file mode 100644 index 00000000000..5dc47e5300f --- /dev/null +++ b/mobile/src/session/synthetic-workspace-route.ts @@ -0,0 +1,8 @@ +import { isFloatingWorkspaceWorktreeId } from './floating-workspace' + +/** Route ids that name no managed worktree, so the host can never list or resolve them. + * Every "is this workspace still there?" check must exempt them, or their permanent + * absence from the catalog reads as a deletion. */ +export function isSyntheticWorkspaceRoute(worktreeId: string): boolean { + return worktreeId.startsWith('folder:') || isFloatingWorkspaceWorktreeId(worktreeId) +} diff --git a/mobile/src/session/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts index 8ac565c7684..45e64205d1d 100644 --- a/mobile/src/session/use-live-worktree-name.test.ts +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -175,7 +175,7 @@ describe('useLiveWorktreeName request volume', () => { connState: 'connected', routeName: undefined, worktreeId: 'global-floating-terminal' - }) + }).name return null } @@ -202,7 +202,7 @@ describe('useLiveWorktreeName request volume', () => { let renderer: ReactTestRenderer | null = null function RouteHarness(props: { routeName?: string; worktreeId: string }): null { - const name = useLiveWorktreeName({ + const { name } = useLiveWorktreeName({ client, connState: 'connected', routeName: props.routeName, @@ -248,4 +248,62 @@ describe('useLiveWorktreeName request volume', () => { expect(firstNameByWorktree.get('global-floating-terminal')).toBe('Floating Workspace') expect(firstNameByWorktree.get('repo-2::/worktree')).toBe('Next workspace') }) + + // The bounce in use-missing-worktree-bounce.ts rides this poll rather than adding a second + // RPC, so the failure branch has to publish a verdict instead of returning early. + it('reports the host-proven verdict from the same poll', async () => { + let resolution = '' + function VerdictHarness(): null { + resolution = useLiveWorktreeName({ + client, + connState: 'connected', + routeName: 'Route name', + worktreeId: 'repo-1::/worktree' + }).resolution + return null + } + const mount = async (): Promise => { + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(VerdictHarness)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + } + + await mount() + expect(resolution).toBe('present') + + act(() => renderer?.unmount()) + renderer = null + sendRequest.mockResolvedValue({ + id: 'worktree-show', + ok: false, + error: { code: 'selector_not_found', message: 'Selector not found' }, + _meta: { runtimeId: 'runtime-1' } + }) + await mount() + // Why: a transient desktop repo-scan rejection also answers selector_not_found, + // so one miss stays unproven; only the confirming poll may say 'missing'. + expect(resolution).toBe('unknown') + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(resolution).toBe('missing') + + act(() => renderer?.unmount()) + renderer = null + // A dropped socket is not a deletion, so it must leave the verdict unproven. + sendRequest.mockResolvedValue({ + id: 'worktree-show', + ok: false, + error: { code: 'runtime_busy', message: 'Runtime busy' }, + _meta: { runtimeId: 'runtime-1' } + }) + await mount() + expect(resolution).toBe('unknown') + }) }) diff --git a/mobile/src/session/use-live-worktree-name.ts b/mobile/src/session/use-live-worktree-name.ts index 53ef1cf1bbf..a2bc6d1175c 100644 --- a/mobile/src/session/use-live-worktree-name.ts +++ b/mobile/src/session/use-live-worktree-name.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useFocusEffect } from 'expo-router' import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events' import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id' @@ -6,6 +6,10 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcSuccess } from '../transport/types' import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name' import { FLOATING_WORKSPACE_TITLE, isFloatingWorkspaceWorktreeId } from './floating-workspace' +import { + classifyWorktreeShowResponse, + type WorktreeShowResolution +} from '../worktree/worktree-show-resolution' const WORKTREE_NAME_FALLBACK_POLL_MS = 3000 @@ -16,7 +20,19 @@ type Params = { worktreeId: string } -export function useLiveWorktreeName({ client, connState, routeName, worktreeId }: Params): string { +export type LiveWorktreeName = { + name: string + /** What the host last proved about this worktree still existing — see the bounce in + * use-missing-worktree-bounce.ts. */ + resolution: WorktreeShowResolution +} + +export function useLiveWorktreeName({ + client, + connState, + routeName, + worktreeId +}: Params): LiveWorktreeName { // Why: the floating sentinel has no worktree record, so worktree.show would // fail forever and keep the 3s fallback poll alive; its title is fixed. const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId) @@ -25,6 +41,15 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } worktreeId, name: routeNameHint })) + // Why: keyed by id so a verdict about the previous route can never survive into the next one. + const [resolved, setResolved] = useState<{ + worktreeId: string + resolution: WorktreeShowResolution + }>(() => ({ worktreeId, resolution: 'unknown' })) + // Why: a transient desktop repo-scan rejection collapses the catalog to zero rows and + // answers selector_not_found for a live worktree — one miss is suspicion, not proof. + // The fallback poll guarantees a confirming read (a failed show never stops it). + const missingStreakRef = useRef({ worktreeId, count: 0 }) useEffect(() => { setWorktreeName((current) => @@ -34,6 +59,12 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } ) }, [routeNameHint, worktreeId]) + useEffect(() => { + setResolved((current) => + current.worktreeId === worktreeId ? current : { worktreeId, resolution: 'unknown' } + ) + }, [worktreeId]) + useFocusEffect( useCallback(() => { if (isFloatingWorkspace || !client || connState !== 'connected') { @@ -60,7 +91,26 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } const response = await client.sendRequest('worktree.show', { worktree: `id:${worktreeId}` }) - if (stale || generation !== refreshGeneration || !response.ok) { + if (stale || generation !== refreshGeneration) { + return + } + const classified = classifyWorktreeShowResponse(response) + const streak = missingStreakRef.current + missingStreakRef.current = { + worktreeId, + count: + classified === 'missing' + ? (streak.worktreeId === worktreeId ? streak.count : 0) + 1 + : 0 + } + const resolution = + classified === 'missing' && missingStreakRef.current.count < 2 ? 'unknown' : classified + setResolved((current) => + current.worktreeId === worktreeId && current.resolution === resolution + ? current + : { worktreeId, resolution } + ) + if (!response.ok) { return } const result = (response as RpcSuccess).result as { @@ -148,7 +198,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } ) if (isFloatingWorkspace) { - return FLOATING_WORKSPACE_TITLE + // The sentinel has no worktree record to resolve, so nothing is ever proven about it. + return { name: FLOATING_WORKSPACE_TITLE, resolution: 'unknown' } + } + return { + name: worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint, + resolution: resolved.worktreeId === worktreeId ? resolved.resolution : 'unknown' } - return worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint } diff --git a/mobile/src/session/use-missing-worktree-bounce.test.ts b/mobile/src/session/use-missing-worktree-bounce.test.ts new file mode 100644 index 00000000000..209486bc8d1 --- /dev/null +++ b/mobile/src/session/use-missing-worktree-bounce.test.ts @@ -0,0 +1,79 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeShowResolution } from '../worktree/worktree-show-resolution' +import { isSyntheticWorkspaceRoute } from './synthetic-workspace-route' +import { + shouldBounceMissingWorktree, + useMissingWorktreeBounce +} from './use-missing-worktree-bounce' + +describe('shouldBounceMissingWorktree', () => { + it('bounces only a host-proven missing worktree', () => { + expect(shouldBounceMissingWorktree('repo::wt', 'missing')).toBe(true) + expect(shouldBounceMissingWorktree('repo::wt', 'unknown')).toBe(false) + expect(shouldBounceMissingWorktree('repo::wt', 'present')).toBe(false) + }) + + it('never bounces synthetic routes the host cannot resolve', () => { + expect(isSyntheticWorkspaceRoute('folder:/Users/x/dir')).toBe(true) + expect(shouldBounceMissingWorktree('folder:/Users/x/dir', 'missing')).toBe(false) + expect(shouldBounceMissingWorktree('global-floating-terminal', 'missing')).toBe(false) + }) +}) + +describe('useMissingWorktreeBounce', () => { + let renderer: ReactTestRenderer | null = null + const bounce = vi.fn() + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + bounce.mockReset() + renderer = null + }) + + function Harness(props: { worktreeId: string; resolution: WorktreeShowResolution }): null { + useMissingWorktreeBounce({ + hostId: 'host-1', + worktreeId: props.worktreeId, + resolution: props.resolution, + bounce + }) + return null + } + + function render(worktreeId: string, resolution: WorktreeShowResolution): void { + act(() => { + const element = createElement(Harness, { worktreeId, resolution }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + it('bounces exactly once per worktree even across re-renders', () => { + render('repo::wt', 'unknown') + expect(bounce).not.toHaveBeenCalled() + + render('repo::wt', 'missing') + expect(bounce).toHaveBeenCalledExactlyOnceWith('host-1') + + // Why: navigation lands after the render, so the pre-unmount renders must not re-fire. + render('repo::wt', 'missing') + expect(bounce).toHaveBeenCalledTimes(1) + act(() => renderer?.unmount()) + }) + + it('re-arms for a different worktree on the reused screen', () => { + render('repo::wt-1', 'missing') + expect(bounce).toHaveBeenCalledTimes(1) + + render('repo::wt-2', 'unknown') + expect(bounce).toHaveBeenCalledTimes(1) + render('repo::wt-2', 'missing') + expect(bounce).toHaveBeenCalledTimes(2) + act(() => renderer?.unmount()) + }) +}) diff --git a/mobile/src/session/use-missing-worktree-bounce.ts b/mobile/src/session/use-missing-worktree-bounce.ts new file mode 100644 index 00000000000..cadc9ea7901 --- /dev/null +++ b/mobile/src/session/use-missing-worktree-bounce.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from 'react' +import { isSyntheticWorkspaceRoute } from './synthetic-workspace-route' +import type { WorktreeShowResolution } from '../worktree/worktree-show-resolution' + +export function shouldBounceMissingWorktree( + worktreeId: string, + resolution: WorktreeShowResolution +): boolean { + return resolution === 'missing' && !isSyntheticWorkspaceRoute(worktreeId) +} + +/** Sends the route back to the host index once the host has *proven* the worktree is gone — + * a workspace deleted on the desktop while the phone held the link (Resume, a notification, + * a cold deep link) otherwise lands on a session screen whose every RPC fails. */ +export function useMissingWorktreeBounce(args: { + hostId: string + worktreeId: string + resolution: WorktreeShowResolution + bounce: (hostId: string) => void +}): void { + const { hostId, worktreeId, resolution } = args + // Why: navigation takes effect after this render, so without a latch the renders before + // unmount would each fire again — and it lets callers pass an inline bounce closure. + const bouncedRef = useRef(null) + const bounceRef = useRef(args.bounce) + // Why: synced in an effect (render must stay pure); declared first so the + // bounce effect below always sees the freshest closure in the same commit. + useEffect(() => { + bounceRef.current = args.bounce + }) + useEffect(() => { + if (!hostId || bouncedRef.current === worktreeId) { + return + } + if (!shouldBounceMissingWorktree(worktreeId, resolution)) { + return + } + bouncedRef.current = worktreeId + bounceRef.current(hostId) + }, [hostId, worktreeId, resolution]) +} diff --git a/mobile/src/session/use-mobile-diff-review-controller.test.ts b/mobile/src/session/use-mobile-diff-review-controller.test.ts new file mode 100644 index 00000000000..7c0a80455bc --- /dev/null +++ b/mobile/src/session/use-mobile-diff-review-controller.test.ts @@ -0,0 +1,133 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import type { ReviewScreenState } from './mobile-diff-review-screen-model' +import { useMobileDiffReviewController } from './use-mobile-diff-review-controller' + +const loadSnapshot = vi.hoisted(() => vi.fn()) +vi.mock('./mobile-diff-review-loaders', () => ({ + loadMobileDiffReviewSnapshot: loadSnapshot, + loadMobileDiffReviewDiff: vi.fn().mockResolvedValue({ kind: 'idle' }) +})) +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-haptics', () => ({ + impactAsync: vi.fn(), + notificationAsync: vi.fn(), + selectionAsync: vi.fn(), + performAndroidHapticsAsync: vi.fn(), + AndroidHaptics: {}, + ImpactFeedbackStyle: {}, + NotificationFeedbackType: {} +})) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) + +const client = { sendRequest: vi.fn() } as unknown as RpcClient + +function readySnapshot(branch: string): ReviewScreenState { + return { + kind: 'ready', + status: { entries: [], branch, head: 'abc123' }, + comments: [], + reviewState: { reviewedKeys: [] }, + branchCompare: null + } as unknown as ReviewScreenState +} + +describe('useMobileDiffReviewController', () => { + let renderer: ReactTestRenderer | null = null + let screenState: ReviewScreenState = { kind: 'loading' } + + function Probe({ connState }: { connState: ConnectionState }): null { + const controller = useMobileDiffReviewController({ + client, + connState, + hostId: 'host-1', + worktreeId: 'wt-1', + name: 'review', + initialFilter: 'all', + initialTarget: null, + onOpenSession: () => {}, + onReconnect: () => {} + }) + screenState = controller.screenState + return null + } + + async function update(connState: ConnectionState): Promise { + await act(async () => { + renderer?.update(createElement(Probe, { connState })) + await Promise.resolve() + }) + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + loadSnapshot.mockReset() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('keeps the loaded review across a disconnect and its reconnect reload', async () => { + let releaseReload: (() => void) | null = null + loadSnapshot.mockResolvedValueOnce(readySnapshot('feature/one')).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseReload = () => resolve(readySnapshot('feature/two')) + }) + ) + + await act(async () => { + renderer = create(createElement(Probe, { connState: 'connected' })) + await Promise.resolve() + }) + expect(screenState).toMatchObject({ kind: 'ready' }) + + await update('reconnecting') + expect(screenState).toMatchObject({ kind: 'ready', status: { branch: 'feature/one' } }) + + await update('connected') + expect(screenState).toMatchObject({ kind: 'ready', status: { branch: 'feature/one' } }) + + await act(async () => { + releaseReload?.() + await Promise.resolve() + }) + expect(screenState).toMatchObject({ kind: 'ready', status: { branch: 'feature/two' } }) + }) + + it('keeps the loaded review when the reconnect reload rejects', async () => { + loadSnapshot + .mockResolvedValueOnce(readySnapshot('feature/one')) + .mockRejectedValueOnce(new Error('snapshot fetch failed')) + + await act(async () => { + renderer = create(createElement(Probe, { connState: 'connected' })) + await Promise.resolve() + }) + expect(screenState).toMatchObject({ kind: 'ready' }) + + await update('reconnecting') + await update('connected') + expect(loadSnapshot).toHaveBeenCalledTimes(2) + // Why (F10): a failed refresh must not replace the review on screen with an error. + expect(screenState).toMatchObject({ kind: 'ready', status: { branch: 'feature/one' } }) + }) + + it('waits for the desktop when the drop lands before the review loads', async () => { + loadSnapshot.mockReturnValueOnce(new Promise(() => {})) + + await act(async () => { + renderer = create(createElement(Probe, { connState: 'connected' })) + await Promise.resolve() + }) + expect(screenState).toMatchObject({ kind: 'loading' }) + + await update('disconnected') + expect(screenState).toMatchObject({ kind: 'error', message: 'Waiting for desktop...' }) + }) +}) diff --git a/mobile/src/session/use-mobile-diff-review-controller.ts b/mobile/src/session/use-mobile-diff-review-controller.ts index 5b1b5fcf935..d57276f1f43 100644 --- a/mobile/src/session/use-mobile-diff-review-controller.ts +++ b/mobile/src/session/use-mobile-diff-review-controller.ts @@ -16,15 +16,12 @@ import { findMobileDiffReviewInitialIndex, type MobileDiffReviewInitialTarget } from './mobile-diff-review-positioning' -import { - loadMobileDiffReviewDiff, - loadMobileDiffReviewSnapshot -} from './mobile-diff-review-loaders' +import { loadMobileDiffReviewSnapshot } from './mobile-diff-review-loaders' +import { useMobileDiffReviewDiffLoading } from './use-mobile-diff-review-diff-loading' import { canOpenMobileBranchCompareDiff } from '../source-control/mobile-branch-compare' import type { ComposerState, ReviewDiffLine, - ReviewDiffState, ReviewScreenState, SendSheetState } from './mobile-diff-review-screen-model' @@ -60,7 +57,6 @@ export function useMobileDiffReviewController(input: ControllerInput) { const seededInitialTargetRef = useRef(false) const initialTargetKey = initialTarget ? `${initialTarget.area}\0${initialTarget.filePath}` : '' const [screenState, setScreenState] = useState({ kind: 'loading' }) - const [diffState, setDiffState] = useState({ kind: 'idle' }) const [filter, setFilter] = useState(initialFilter) const [currentIndex, setCurrentIndex] = useState(0) const [activeHunkIndex, setActiveHunkIndex] = useState(null) @@ -82,11 +78,15 @@ export function useMobileDiffReviewController(input: ControllerInput) { setScreenState({ kind: 'error', message: 'Missing worktree' }) return } + // Why (F10): a loaded review outlives a blip — the waiting state is for a screen with nothing + // to show, and this branch (not the one below it) is the one a drop actually reaches. + const keepReady = (fallback: ReviewScreenState) => (prev: ReviewScreenState) => + prev.kind === 'ready' ? prev : fallback if (!client || connState !== 'connected') { - setScreenState({ kind: 'error', message: 'Waiting for desktop...' }) + setScreenState(keepReady({ kind: 'error', message: 'Waiting for desktop...' })) return } - setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) + setScreenState(keepReady({ kind: 'loading' })) try { const nextState = await loadMobileDiffReviewSnapshot(client, worktreeId) if (!isCurrent()) { @@ -96,10 +96,14 @@ export function useMobileDiffReviewController(input: ControllerInput) { setActionError(nextState.kind === 'ready' ? (nextState.branchError ?? null) : null) } catch (err) { if (isCurrent()) { - setScreenState({ - kind: 'error', - message: err instanceof Error ? err.message : 'Unable to load review' - }) + // Why (F10): a failed refresh after reconnect must not destroy the review + // already on screen; the error state is for a screen with nothing to show. + setScreenState( + keepReady({ + kind: 'error', + message: err instanceof Error ? err.message : 'Unable to load review' + }) + ) } } }, [client, connState, worktreeId]) @@ -160,42 +164,14 @@ export function useMobileDiffReviewController(input: ControllerInput) { } }, [currentIndex, filteredQueue.length]) - useEffect(() => { - setActiveHunkIndex(null) - if (!currentItem || screenState.kind !== 'ready') { - setDiffState({ kind: 'idle' }) - return - } - if (!client || connState !== 'connected') { - setDiffState({ kind: 'error', itemKey: currentItem.key, message: 'Waiting for desktop...' }) - return - } - let stale = false - setDiffState({ kind: 'loading', itemKey: currentItem.key }) - void loadMobileDiffReviewDiff({ - client, - worktreeId, - item: currentItem, - branchCompare: screenState.branchCompare - }) - .then((nextState) => { - if (!stale) { - setDiffState(nextState) - } - }) - .catch((err: unknown) => { - if (!stale) { - setDiffState({ - kind: 'error', - itemKey: currentItem.key, - message: err instanceof Error ? err.message : 'Unable to load diff' - }) - } - }) - return () => { - stale = true - } - }, [client, connState, currentItem, screenState, worktreeId]) + const diffState = useMobileDiffReviewDiffLoading({ + client, + connState, + worktreeId, + currentItem, + screenState, + setActiveHunkIndex + }) const commentsForCurrentItem = useMemo(() => { if (!currentItem || screenState.kind !== 'ready') { diff --git a/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts b/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts new file mode 100644 index 00000000000..3245c228254 --- /dev/null +++ b/mobile/src/session/use-mobile-diff-review-diff-loading.test.ts @@ -0,0 +1,119 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue' +import type { ReviewDiffState, ReviewScreenState } from './mobile-diff-review-screen-model' +import { useMobileDiffReviewDiffLoading } from './use-mobile-diff-review-diff-loading' + +const loadDiff = vi.hoisted(() => vi.fn()) +vi.mock('./mobile-diff-review-loaders', () => ({ loadMobileDiffReviewDiff: loadDiff })) + +const client = { sendRequest: vi.fn() } as unknown as RpcClient +// Stable identity, like the controller's useState setter: it is an effect dependency. +const setActiveHunkIndex = () => {} +const currentItem = { key: 'item-1', filePath: 'src/app.ts' } as MobileDiffReviewQueueItem +const readyScreen = { kind: 'ready', branchCompare: null } as unknown as ReviewScreenState + +function readyDiff(firstLine: string): ReviewDiffState { + return { + kind: 'ready', + itemKey: 'item-1', + lines: [{ kind: 'context', text: firstLine }], + hunks: [], + truncated: false + } as unknown as ReviewDiffState +} + +describe('useMobileDiffReviewDiffLoading', () => { + let renderer: ReactTestRenderer | null = null + let diffState: ReviewDiffState = { kind: 'idle' } + + function Probe({ connState }: { connState: ConnectionState }): null { + diffState = useMobileDiffReviewDiffLoading({ + client, + connState, + worktreeId: 'wt-1', + currentItem, + screenState: readyScreen, + setActiveHunkIndex + }) + return null + } + + async function render(connState: ConnectionState): Promise { + await act(async () => { + renderer = create(createElement(Probe, { connState })) + await Promise.resolve() + }) + } + + async function update(connState: ConnectionState): Promise { + await act(async () => { + renderer?.update(createElement(Probe, { connState })) + await Promise.resolve() + }) + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + loadDiff.mockReset() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('keeps the loaded diff when the reconnect refetch rejects', async () => { + loadDiff + .mockResolvedValueOnce(readyDiff('before the drop')) + .mockRejectedValueOnce(new Error('diff fetch failed')) + + await render('connected') + expect(diffState).toMatchObject({ kind: 'ready' }) + + await update('reconnecting') + await update('connected') + // Why (F10): a failed refetch must not erase the diff (or hunk context) on screen. + expect(diffState).toMatchObject({ kind: 'ready', lines: [{ text: 'before the drop' }] }) + expect(loadDiff).toHaveBeenCalledTimes(2) + }) + + it('keeps the loaded diff through a disconnect and its reconnect refetch', async () => { + let releaseRefetch: (() => void) | null = null + loadDiff.mockResolvedValueOnce(readyDiff('before the drop')).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRefetch = () => resolve(readyDiff('after the drop')) + }) + ) + + await render('connected') + expect(diffState).toMatchObject({ kind: 'ready' }) + + await update('reconnecting') + expect(diffState).toMatchObject({ kind: 'ready', lines: [{ text: 'before the drop' }] }) + + await update('connected') + expect(diffState).toMatchObject({ kind: 'ready', lines: [{ text: 'before the drop' }] }) + + await act(async () => { + releaseRefetch?.() + await Promise.resolve() + }) + expect(diffState).toMatchObject({ kind: 'ready', lines: [{ text: 'after the drop' }] }) + expect(loadDiff).toHaveBeenCalledTimes(2) + }) + + it('waits for the desktop when the drop lands before any diff is loaded', async () => { + loadDiff.mockReturnValueOnce(new Promise(() => {})) + + await render('connected') + expect(diffState).toMatchObject({ kind: 'loading', itemKey: 'item-1' }) + + await update('disconnected') + expect(diffState).toMatchObject({ kind: 'error', message: 'Waiting for desktop...' }) + }) +}) diff --git a/mobile/src/session/use-mobile-diff-review-diff-loading.ts b/mobile/src/session/use-mobile-diff-review-diff-loading.ts new file mode 100644 index 00000000000..db2e1540e1b --- /dev/null +++ b/mobile/src/session/use-mobile-diff-review-diff-loading.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from 'react' +import type { ConnectionState } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { loadMobileDiffReviewDiff } from './mobile-diff-review-loaders' +import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue' +import type { ReviewDiffState, ReviewScreenState } from './mobile-diff-review-screen-model' + +type DiffLoadingInput = { + client: RpcClient | null + connState: ConnectionState + worktreeId: string + currentItem: MobileDiffReviewQueueItem | null + screenState: ReviewScreenState + setActiveHunkIndex: (index: number | null) => void +} + +// Owns the diff body for the reviewed item. Split out of the review controller so the loaded diff +// can survive a transport blip: a drop re-runs this effect, and (F10) a diff already on screen for +// the same item stays there instead of being replaced by "Waiting for desktop..." or a spinner. +export function useMobileDiffReviewDiffLoading(input: DiffLoadingInput): ReviewDiffState { + const { client, connState, worktreeId, currentItem, screenState, setActiveHunkIndex } = input + const [diffState, setDiffState] = useState({ kind: 'idle' }) + const hunkResetKeyRef = useRef(null) + // Why: depend on the two fields this effect reads, not the screenState object — + // an identity-only change must not restart the git.diff request. + const screenReady = screenState.kind === 'ready' + const branchCompare = screenState.kind === 'ready' ? screenState.branchCompare : null + + useEffect(() => { + // Why (F10): a connection blip re-runs this effect; the reader's hunk position must + // survive it and reset only when the reviewed item actually changes. + const hunkKey = currentItem?.key ?? null + if (hunkResetKeyRef.current !== hunkKey) { + hunkResetKeyRef.current = hunkKey + setActiveHunkIndex(null) + } + if (!currentItem || !screenReady) { + setDiffState({ kind: 'idle' }) + return + } + const itemKey = currentItem.key + const keepLoadedDiff = (fallback: ReviewDiffState) => (prev: ReviewDiffState) => + prev.kind === 'ready' && prev.itemKey === itemKey ? prev : fallback + if (!client || connState !== 'connected') { + setDiffState(keepLoadedDiff({ kind: 'error', itemKey, message: 'Waiting for desktop...' })) + return + } + let stale = false + setDiffState(keepLoadedDiff({ kind: 'loading', itemKey })) + void loadMobileDiffReviewDiff({ + client, + worktreeId, + item: currentItem, + branchCompare + }) + .then((nextState) => { + if (!stale) { + setDiffState(nextState) + } + }) + .catch((err: unknown) => { + if (!stale) { + // Why (F10): a rejected reconnect refetch must not erase the diff on screen. + setDiffState( + keepLoadedDiff({ + kind: 'error', + itemKey, + message: err instanceof Error ? err.message : 'Unable to load diff' + }) + ) + } + }) + return () => { + stale = true + } + }, [client, connState, currentItem, screenReady, branchCompare, setActiveHunkIndex, worktreeId]) + + return diffState +} diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts index a795d8d9ed6..6f48b2a771d 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -98,7 +98,13 @@ describe('useMobileFileTapHandlers', () => { expect(sendRequest).toHaveBeenCalledWith( 'files.resolveTerminalPath', - { worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'terminal-1', cwd: '/repo/sub' }, + { + worktree: 'id:wt-1', + pathText: 'index.ts', + terminal: 'terminal-1', + cwd: '/repo/sub', + crossWorkspace: true + }, { timeoutMs: 10_000 } ) }) @@ -126,7 +132,7 @@ describe('useMobileFileTapHandlers', () => { expect(sendRequest).toHaveBeenCalledWith( 'files.resolveTerminalPath', - { worktree: 'id:wt-1', pathText: 'mobile/src/x.ts' }, + { worktree: 'id:wt-1', pathText: 'mobile/src/x.ts', crossWorkspace: true }, { timeoutMs: 10_000 } ) expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12") diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts index 8c81f0255c2..460ffca54cd 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -7,9 +7,11 @@ import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dism describe('useMobileNativeChatAskDismiss', () => { let renderer: ReactTestRenderer | null = null let state: ReturnType | null = null + let renders = 0 beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true + renders = 0 }) afterEach(() => { @@ -18,11 +20,51 @@ describe('useMobileNativeChatAskDismiss', () => { state = null }) - function Harness({ prompt }: { prompt: AskPrompt }): null { - state = useMobileNativeChatAskDismiss(prompt) + function Harness({ + prompt, + detectedPrompt = prompt, + scopeKey = 'tab-1', + sessionKey = 'session-1', + observing = true + }: { + prompt: AskPrompt | null + detectedPrompt?: AskPrompt | null + scopeKey?: string | null + sessionKey?: string | null + observing?: boolean + }): null { + renders += 1 + state = useMobileNativeChatAskDismiss({ + ask: prompt, + detectedAsk: detectedPrompt, + scopeKey, + sessionKey, + observing + }) return null } + async function mount(props: Parameters[0]): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, props)) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function update(props: Parameters[0]): Promise { + await act(async () => renderer?.update(createElement(Harness, props))) + } + const first: AskPrompt = { questions: [ { question: 'same first', multiSelect: false, options: [] }, @@ -37,24 +79,129 @@ describe('useMobileNativeChatAskDismiss', () => { } it('shows a structurally different replacement without an intervening null', async () => { - const original = console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { - return - } - original(...args) - }) - try { - await act(async () => { - renderer = create(createElement(Harness, { prompt: first })) - }) - } finally { - consoleSpy.mockRestore() - } + await mount({ prompt: first }) act(() => state?.dismissAsk()) expect(state?.showAsk).toBe(false) - await act(async () => renderer?.update(createElement(Harness, { prompt: replacement }))) + await update({ prompt: replacement }) + expect(state?.showAsk).toBe(true) + + await update({ prompt: first }) expect(state?.showAsk).toBe(true) }) + + it('keeps a dismissal while status gating hides a still-detected prompt', async () => { + await mount({ prompt: first }) + const acceptedDismiss = state!.dismissAsk + + await update({ prompt: null, detectedPrompt: first }) + act(() => acceptedDismiss()) + await update({ prompt: first }) + + expect(state?.showAsk).toBe(false) + }) + + it('ignores an answer that settles after its prompt cleared', async () => { + await mount({ prompt: first }) + const lateDismiss = state!.dismissAsk + + await update({ prompt: null }) + act(() => lateDismiss()) + await update({ prompt: first }) + + expect(state?.showAsk).toBe(true) + }) + + it('keeps a dismissal taken on-chat across a chat→terminal→chat toggle', async () => { + // The common case: answer the card, then toggle to the terminal. The prompt + // derives to null while hidden, and that null must not retire the dismissal. + await mount({ prompt: first }) + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) + + await update({ prompt: null, observing: false }) + await update({ prompt: first, observing: true }) + + expect(state?.showAsk).toBe(false) + }) + + it('keeps a dismissal across a chat→terminal→chat toggle', async () => { + // While the chat surface is hidden the prompt derives to null; that null + // proves nothing about the agent and must not reset the dismissal. + await mount({ prompt: first }) + const acceptedDismiss = state!.dismissAsk + + await update({ prompt: null, observing: false }) + act(() => acceptedDismiss()) + await update({ prompt: first, observing: true }) + + expect(state?.showAsk).toBe(false) + }) + + it('forgets the dismissal once the prompt clears while observable', async () => { + await mount({ prompt: first }) + act(() => state?.dismissAsk()) + + // Agent moved on: the prompt cleared with chat visible. + await update({ prompt: null, observing: true }) + await update({ prompt: first, observing: true }) + + expect(state?.showAsk).toBe(true) + }) + + it('scopes the dismissal to the tab that showed the card', async () => { + await mount({ prompt: first, scopeKey: 'tab-1' }) + act(() => state?.dismissAsk()) + + // The same question on another tab is a different pending prompt. + await update({ prompt: first, scopeKey: 'tab-2' }) + expect(state?.showAsk).toBe(true) + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) + + // Dismissing tab 2 must not overwrite tab 1's dismissal. + await update({ prompt: first, scopeKey: 'tab-1' }) + expect(state?.showAsk).toBe(false) + + await update({ prompt: first, scopeKey: 'tab-2' }) + expect(state?.showAsk).toBe(false) + }) + + it('shows an identical prompt after the tab starts a new provider session', async () => { + await mount({ prompt: first, sessionKey: 'session-1' }) + act(() => state?.dismissAsk()) + + await update({ prompt: first, sessionKey: 'session-2' }) + + expect(state?.showAsk).toBe(true) + }) + + it('reports nothing to show without a prompt', async () => { + await mount({ prompt: null }) + expect(state?.showAsk).toBe(false) + }) + + it('does not re-render a scope with nothing dismissed when the prompt changes', async () => { + await mount({ prompt: first }) + const afterMount = renders + + await update({ prompt: replacement }) + + // One render for the prop change and no more: the reset effect must hand back + // the same Map when this scope has no dismissal, or every observed prompt + // change commits a fresh one. The hook sits on the session route, so that + // wasted commit re-renders the whole session surface. + expect(renders).toBe(afterMount + 1) + }) + + it('records a settled answer against its originating tab', async () => { + await mount({ prompt: first, scopeKey: 'tab-1' }) + const tabOneDismiss = state!.dismissAsk + + await update({ prompt: replacement, scopeKey: 'tab-2' }) + act(() => tabOneDismiss()) + await update({ prompt: first, scopeKey: 'tab-1' }) + + expect(state?.showAsk).toBe(false) + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts index 8f7289f0753..35d6cf1c7c8 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -1,26 +1,79 @@ -import { useEffect, useState } from 'react' -import type { AskPrompt } from './mobile-native-chat-ask' +import { useEffect, useMemo, useRef, useState } from 'react' +import { nativeChatAskDismissKey, type AskPrompt } from './mobile-native-chat-ask' + +type AskDismissal = { sessionKey: string | null; askKey: string } +type DetectedAsk = { sessionKey: string | null; askKey: string | null } /** Track the answered-ask key so the lingering live status doesn't re-show the * same card. The agent emits a post-tool event with the same prompt right after - * an answer, so the card is hidden until a genuinely different question arrives. */ -export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): { + * an answer, so the card is hidden until a genuinely different question arrives. + * + * Owned by the controller, not the chat subtree: the overlay unmounts on a + * chat↔terminal view toggle, and a dismissal must survive that round-trip. */ +export function useMobileNativeChatAskDismiss(args: { + ask: AskPrompt | null + /** Ungated prompt payload. A working/done status hides the card but does not + * prove the sticky prompt itself cleared. Required, and never defaulted to + * `ask`: reading the gated prompt as the detected one is the resurfacing bug + * this hook exists to close. */ + detectedAsk: AskPrompt | null + /** Tab scope retains dismissals across tab switches. */ + scopeKey: string | null + /** Provider-session identity distinguishes restarts without growing the tab map. */ + sessionKey: string | null + /** True while the chat surface can actually observe the prompt. A null ask it + * cannot see — off-chat, or before a re-subscribed transcript lands — proves + * nothing and must not reset the dismissal; that reset resurfaced the card. */ + observing: boolean +}): { askKey: string | null showAsk: boolean dismissAsk: () => void } { - const askKey = ask ? JSON.stringify(ask.questions) : null - const [dismissedAskKey, setDismissedAskKey] = useState(null) - // Once the prompt clears (agent moved on), forget the dismissal so a later - // question — even an identical one — shows again instead of staying hidden. - const askPresent = ask != null + const { ask, detectedAsk, scopeKey, sessionKey, observing } = args + const askKey = useMemo(() => nativeChatAskDismissKey(ask), [ask]) + const detectedAskKey = useMemo(() => nativeChatAskDismissKey(detectedAsk), [detectedAsk]) + const detectedByScopeRef = useRef(new Map()) + const [dismissedByScope, setDismissedByScope] = useState>( + () => new Map() + ) useEffect(() => { - if (!askPresent) { - setDismissedAskKey(null) + if (observing) { + detectedByScopeRef.current.set(scopeKey, { sessionKey, askKey: detectedAskKey }) } - }, [askPresent]) - const showAsk = askPresent && askKey !== dismissedAskKey - const dismissAsk = (): void => setDismissedAskKey(askKey) + }, [observing, detectedAskKey, scopeKey, sessionKey]) + // A cleared or genuinely different detected prompt retires the old dismissal. + useEffect(() => { + if (observing) { + setDismissedByScope((previous) => { + const dismissed = previous.get(scopeKey) + if ( + dismissed === undefined || + (dismissed.sessionKey === sessionKey && dismissed.askKey === detectedAskKey) + ) { + return previous + } + const next = new Map(previous) + next.delete(scopeKey) + return next + }) + } + }, [observing, detectedAskKey, scopeKey, sessionKey]) + const dismissed = dismissedByScope.get(scopeKey) + const showAsk = + askKey !== null && !(dismissed?.sessionKey === sessionKey && dismissed.askKey === askKey) + const dismissAsk = (): void => { + const detected = detectedByScopeRef.current.get(scopeKey) + if (askKey !== null && detected?.sessionKey === sessionKey && detected.askKey === askKey) { + setDismissedByScope((previous) => { + const current = previous.get(scopeKey) + if (current?.sessionKey === sessionKey && current.askKey === askKey) { + return previous + } + return new Map(previous).set(scopeKey, { sessionKey, askKey }) + }) + } + } return { askKey, showAsk, dismissAsk } } diff --git a/mobile/src/session/use-mobile-native-chat-control-state.ts b/mobile/src/session/use-mobile-native-chat-control-state.ts new file mode 100644 index 00000000000..1f92a352a7d --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-control-state.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react' +import { canStopNativeChatAgent } from '../../../src/shared/native-chat-action-availability' + +const CONTROL_STATE_SETTLE_MS = 600 + +export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' + +function useDelayedActivation(active: boolean): boolean { + const [delayedActive, setDelayedActive] = useState(false) + useEffect(() => { + if (!active) { + setDelayedActive(false) + return + } + const timer = setTimeout(() => setDelayedActive(true), CONTROL_STATE_SETTLE_MS) + return () => clearTimeout(timer) + }, [active]) + return active && delayedActive +} + +export function useMobileNativeChatControlState(args: { + inputLockReason: MobileNativeChatInputLockReason | null + agentStatusLive: boolean + stopTargetWritable: boolean + stopCommandAvailable: boolean +}): { + lockReason: MobileNativeChatInputLockReason | null + statusStale: boolean + canStopAgent: boolean +} { + const rawLockHeld = args.inputLockReason !== null + const [lockHeld, setLockHeld] = useState(false) + useEffect(() => { + if (rawLockHeld === lockHeld) { + return + } + const timer = setTimeout(() => setLockHeld(rawLockHeld), CONTROL_STATE_SETTLE_MS) + return () => clearTimeout(timer) + }, [lockHeld, rawLockHeld]) + const statusStale = useDelayedActivation(!args.agentStatusLive) + + return { + lockReason: lockHeld ? (args.inputLockReason ?? 'waiting') : null, + statusStale, + canStopAgent: canStopNativeChatAgent({ + targetWritable: args.stopTargetWritable, + stopCommandAvailable: args.stopCommandAvailable + }) + } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index cef944ce7a4..78c38323ae6 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -15,6 +15,12 @@ const holdUnconfirmedSend = vi.fn() const viewMode = { isTabChatView: (_tabId: string) => true } const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } const draftsArgs: Record[] = [] +const promptsState = { + permission: null as unknown, + question: null as unknown, + detectedAsk: null as unknown, + ask: null as unknown +} // The controller composes many session hooks; each is mocked to a minimal shape // so this test isolates the send seam (outcome -> drafts accounting). @@ -34,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ composerText: '', setComposerText: vi.fn(), pending: [], + imagePreviewsByMessageId: {}, captureSendOrigin, readSeededLaunchDraft: () => null, readSeededLaunchDraftSeed: () => null, @@ -45,7 +52,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ } })) vi.mock('./use-mobile-native-chat-prompts', () => ({ - useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null }) + useMobileNativeChatPrompts: () => promptsState })) const answerSendArgs: { streamIdentity?: string }[] = [] vi.mock('./use-mobile-native-chat-answer-send', () => ({ @@ -80,6 +87,7 @@ import { useMobileNativeChatController, type MobileNativeChatController } from './use-mobile-native-chat-controller' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome) @@ -445,6 +453,292 @@ describe('useMobileNativeChatController launch-draft wiring', () => { }) }) +describe('useMobileNativeChatController ask dismissal across a transcript reload', () => { + let renderer: ReactTestRenderer | null = null + let controller: MobileNativeChatController | null = null + const clientStub = { sendRequest: vi.fn() } + const PROMPT = { questions: [{ question: 'Which path?', multiSelect: false, options: [] }] } + + const chatTab = { type: 'terminal', id: 'tab-1', launchAgent: 'claude' } + /** Mutable so a test can move the user to another tab — or restart the agent + * into a new provider session on the same tab — mid-render. */ + const activeTab = { id: 'tab-1', sessionId: 'session-1' } + + function Harness(): null { + controller = useMobileNativeChatController({ + client: clientStub as unknown as RpcClient, + connState: 'connected', + hostId: 'h', + worktreeId: 'w', + activeSessionTab: { + ...chatTab, + id: activeTab.id, + agentStatus: { agentType: 'claude', providerSession: { id: activeTab.sessionId } } + } as never, + activeSessionTabId: activeTab.id, + activeHandleRef: { current: 'term-1' }, + deviceTokenRef: { current: null }, + nativeChatTranscriptIsLocalReadable: true, + nativeChatInputLeaseReady: true, + onSendError: vi.fn(), + onSendResolved: vi.fn() + }) + return null + } + + /** Re-render under the current viewMode/prompts/session stand-ins. */ + function step(): void { + act(() => { + renderer?.update(createElement(Harness)) + }) + } + + /** Drive the transcript stand-in the way the real hook couples its fields, so + * these tests can only express states the session hook can actually reach: + * `transcriptLoading` is exactly `status === 'loading'`, and `messages` is + * withheld until a read lands. `rows` is what the last landed read left + * behind — 0 means no read has ever landed for this identity. */ + function setTranscript(status: MobileNativeChatStatus, rows = 1): void { + sessionState.status = status + sessionState.transcriptLoading = status === 'loading' + sessionState.messages = + status === 'ready' || status === 'error' ? Array.from({ length: rows }, () => ({})) : [] + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + viewMode.isTabChatView = () => true + setTranscript('ready') + promptsState.ask = PROMPT + promptsState.detectedAsk = PROMPT + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { + if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { + return + } + original(...a) + }) + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + spy.mockRestore() + } + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + controller = null + promptsState.ask = null + promptsState.detectedAsk = null + setTranscript('ready', 0) + viewMode.isTabChatView = () => true + activeTab.id = 'tab-1' + activeTab.sessionId = 'session-1' + }) + + it('scopes the dismissal to the tab it was taken on', () => { + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + // Another tab's agent is parked on a byte-identical question; it is a + // different pending prompt and tab 1's answer must not hide it. + activeTab.id = 'tab-2' + step() + + expect(controller?.nativeChatAsk).not.toBeNull() + }) + + it('shows a restarted session’s identical first question on the same tab', () => { + // A restart, `/clear`, or resume swaps the provider session inside one tab, + // and the next session's first question is often byte-identical — same repo, + // same prompt, same template. Keyed by tab alone the old answer hides it, so + // the live card never renders and the turn sits blocked with nothing to act on. + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + // The restart re-subscribes the transcript, so the read is in flight for a beat. + activeTab.sessionId = 'session-2' + setTranscript('loading') + promptsState.ask = null + promptsState.detectedAsk = null + step() + + setTranscript('ready') + promptsState.ask = PROMPT + promptsState.detectedAsk = PROMPT + step() + + expect(controller?.nativeChatAsk).not.toBeNull() + }) + + it('retires the dismissal only on the ungated prompt, not the gated one', () => { + // The paused gate hides the card whenever the agent is not waiting, but a + // hidden card is no evidence the sticky status prompt cleared. Feeding the + // gated `ask` in as the detected prompt would read that gap as "prompt gone", + // retire the dismissal, and bring the answered card back when it reopens. + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + promptsState.ask = null + step() + + promptsState.ask = PROMPT + step() + + expect(controller?.nativeChatAsk).toBeNull() + }) + + it('keeps the dismissal while the re-subscribed transcript is still empty', () => { + // Toggling views re-subscribes the transcript, so a transcript-derived ask + // reads as null for a beat with chat already visible. Believing that null + // retires the dismissal and the answered card comes back (#12497). + expect(controller?.nativeChatAsk).not.toBeNull() + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + // Chat -> terminal. + viewMode.isTabChatView = () => false + promptsState.ask = null + promptsState.detectedAsk = null + step() + + // Terminal -> chat: observable again, but the transcript read is in flight. + viewMode.isTabChatView = () => true + setTranscript('loading') + step() + + // The read lands and re-derives the same still-pending ask. + setTranscript('ready') + promptsState.ask = PROMPT + promptsState.detectedAsk = PROMPT + step() + + expect(controller?.nativeChatAsk).toBeNull() + }) + + it('accepts an answer taken while the first transcript read is still in flight', () => { + // A status-derived ask renders before any transcript lands, so the load window + // must stay observable whenever a prompt is actually on screen — otherwise the + // dismissal is silently dropped and the answered card never goes away. + act(() => renderer?.unmount()) + setTranscript('loading') + act(() => { + renderer = create(createElement(Harness)) + }) + + expect(controller?.nativeChatAsk).not.toBeNull() + act(() => controller?.dismissNativeChatAsk()) + + expect(controller?.nativeChatAsk).toBeNull() + }) + + it('still retires the dismissal once a settled transcript reports no prompt', () => { + // The load-window guard must not swallow the genuine reset: a prompt that + // clears with the read settled means the agent moved on. + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + promptsState.ask = null + promptsState.detectedAsk = null + step() + + promptsState.ask = PROMPT + promptsState.detectedAsk = PROMPT + step() + + expect(controller?.nativeChatAsk).not.toBeNull() + }) + + it('keeps the dismissal while a dropped client empties the transcript', () => { + // `transcriptLoading` is only true for an in-flight read. A dropped client + // parks the session at 'idle', where the hook withholds `messages` with that + // flag already false — so the derived prompt reads null for a reason that + // says nothing about the agent, and the reset effect retired a live dismissal. + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + setTranscript('idle') + promptsState.ask = null + promptsState.detectedAsk = null + step() + + // Reconnect: the read lands and the same question is still pending. + setTranscript('ready') + promptsState.ask = PROMPT + promptsState.detectedAsk = PROMPT + step() + + expect(controller?.nativeChatAsk).toBeNull() + }) + + it('keeps the dismissal when the first read of a transcript errors', () => { + // The host forwards an initial-drain failure as an error frame carrying an + // EMPTY list, and that frame is not terminal — a real snapshot follows once + // the read recovers. Judging the prompt from that never-populated list + // retires the dismissal, and the recovered read brings the answered card + // back over the composer. + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + setTranscript('error', 0) + promptsState.ask = null + promptsState.detectedAsk = null + step() + + // The read recovers with the same question still pending. + setTranscript('ready') + promptsState.ask = PROMPT + promptsState.detectedAsk = PROMPT + step() + + expect(controller?.nativeChatAsk).toBeNull() + }) + + it('retires the dismissal when a failed read still reports the last transcript', () => { + // A read error that lands on top of rows from an earlier read leaves those + // rows in `messages`, so a prompt that clears under it is real evidence — + // unlike the never-read empty list of 'idle'/'waiting-session'. Treating + // every error as unobservable would freeze the dismissal and hide the next + // identical question for good. + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + setTranscript('error') + promptsState.ask = null + promptsState.detectedAsk = null + step() + + promptsState.ask = PROMPT + promptsState.detectedAsk = PROMPT + step() + + expect(controller?.nativeChatAsk).not.toBeNull() + }) + + it('keeps the dismissal while the tab has no provider session yet', () => { + // 'waiting-session' withholds `messages` the same way, also with + // transcriptLoading false. The tab still shows chat (resolveMobileNativeChat + // resolves from `launchAgent` alone), so a live dismissal is on screen for it. + act(() => controller?.dismissNativeChatAsk()) + expect(controller?.nativeChatAsk).toBeNull() + + setTranscript('waiting-session') + promptsState.ask = null + promptsState.detectedAsk = null + step() + + setTranscript('ready') + promptsState.ask = PROMPT + promptsState.detectedAsk = PROMPT + step() + + expect(controller?.nativeChatAsk).toBeNull() + }) +}) + describe('useMobileNativeChatController streaming scope', () => { let renderer: ReactTestRenderer | null = null let controller: MobileNativeChatController | null = null @@ -508,8 +802,6 @@ describe('useMobileNativeChatController streaming scope', () => { }) it('holds the stream scope and liveness while the user peeks at the terminal', () => { - // Both feed the streaming gate, which lives above the chat view's mount and - // must not be reset by a view toggle. expect(controller?.showNativeChat).toBe(true) const scopeKey = controller?.nativeChatStreamScopeKey expect(scopeKey).toContain('session-1') @@ -525,9 +817,6 @@ describe('useMobileNativeChatController streaming scope', () => { }) it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => { - // `streamIdentity` fences a delayed answer/stop to the route it was armed - // on, so it must still drop its session when chat closes — the scope key is - // the one that has to survive the toggle. Same string while chat is open. const before = answerSendArgs.at(-1)?.streamIdentity expect(before).toBe(controller?.nativeChatStreamScopeKey) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index a23e4414808..7747a3b464e 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,83 +1,29 @@ -import { useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' +import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import { useMobileSessionViewMode } from './use-mobile-session-view-mode' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { - parseAskFromStatus, - type AskAnswerSelection, - type AskPrompt -} from './mobile-native-chat-ask' import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' -import { detectAgentPermission } from './mobile-native-chat-permission' -import { parseAgentQuestion } from './mobile-native-chat-question' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' -import { - useMobileNativeChatDrafts, - type MobileNativeChatPendingMessage -} from './use-mobile-native-chat-drafts' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' +import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' +import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' + +export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 -export type MobileNativeChatController = { - /** Whether a tab's effective view is chat (per-tab override, else the default). */ - isTabChatView: (tabId: string) => boolean - toggleTabChatView: (tabId: string) => void - showNativeChat: boolean - showNativeChatRef: MutableRefObject - /** Resolved agent for the active chat tab (names the empty-state copy). */ - nativeChatAgent: string | null - chatComposerText: string - setChatComposerText: Dispatch> - chatPending: MobileNativeChatPendingMessage[] - nativeChatSession: ReturnType - nativeChatAgentWorking: boolean - /** Whether transport state can still refresh the displayed agent status. */ - nativeChatAgentStatusLive: boolean - /** Exact write gate used by the Stop handler. */ - nativeChatStopTargetWritable: boolean - nativeChatStreamingText?: string - /** Agent mid-turn, regardless of whether chat is the visible view. */ - nativeChatStreamLive: boolean - /** Host/workspace/tab/session scope for stateful streaming suppression. */ - nativeChatStreamScopeKey: string - nativeChatPermission: ReturnType - nativeChatQuestion: ReturnType - nativeChatAsk: ReturnType - handleNativeChatAnswerAsk: ( - prompt: AskPrompt, - selections: AskAnswerSelection[] - ) => Promise - handleNativeChatCancelAsk: () => Promise - handleNativeChatRespondPermission: (text: string) => Promise - handleNativeChatStop: () => void - nativeChatFilePaths: string[] - loadNativeChatFiles: (query: string) => void - handleNativeChatQuestionAnswer: (text: string) => Promise - handleNativeChatSend: (text: string, images?: string[]) => Promise - /** Outcome-preserving send: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a - * caller passes its own `deadline` so the paste it already spent and this text - * body share one budget instead of holding the composer for two. */ - handleNativeChatSendWithOutcome: ( - text: string, - images?: string[], - deadline?: number - ) => Promise - /** Launch-context text still parked on the agent's TUI input line, or null. - * Image sends read it to size their leading clear (one Ctrl+U per line). */ - readSeededLaunchDraft: () => string | null -} - /** Owns mobile native-chat state and teardown outside the already dense session * route. The route remains responsible only for choosing and rendering the view. */ export function useMobileNativeChatController(args: { @@ -119,9 +65,12 @@ export function useMobileNativeChatController(args: { : null const showNativeChat = activeChatResolution != null const showNativeChatRef = useRef(showNativeChat) - showNativeChatRef.current = showNativeChat - const activeChatAgentRef = useRef(activeChatResolution?.agent ?? null) - activeChatAgentRef.current = activeChatResolution?.agent ?? null + const activeChatAgent = activeChatResolution?.agent ?? null + const activeChatAgentRef = useRef(activeChatAgent) + useLayoutEffect(() => { + showNativeChatRef.current = showNativeChat + activeChatAgentRef.current = activeChatAgent + }, [activeChatAgent, showNativeChat]) const activeChatSessionId = activeChatResolution?.sessionId ?? null const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` @@ -133,6 +82,7 @@ export function useMobileNativeChatController(args: { const nativeChatSession = useMobileNativeChatSession({ client, + sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), agent: activeChatResolution?.agent ?? null, sessionId: activeChatSessionId, transcriptPath: activeChatResolution?.transcriptPath ?? null @@ -141,6 +91,7 @@ export function useMobileNativeChatController(args: { composerText: chatComposerText, setComposerText: setChatComposerText, pending: chatPending, + imagePreviewsByMessageId: chatImagePreviewsByMessageId, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, @@ -177,11 +128,30 @@ export function useMobileNativeChatController(args: { const { permission: nativeChatPermission, question: nativeChatQuestion, - ask: nativeChatAsk + detectedAsk: nativeChatDetectedAsk, + ask: nativeChatAskPrompt } = useMobileNativeChatPrompts({ enabled: activeChatResolution != null, status: nativeChatStatus, - messages: nativeChatSession.messages + messages: nativeChatSession.messages, + transcriptLoading: nativeChatSession.transcriptLoading + }) + // A never-read transcript cannot prove that a dismissed prompt cleared. + const nativeChatTranscriptSettled = + nativeChatSession.status === 'ready' || + (nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0) + const nativeChatAskObservable = + showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled) + const { + askKey: nativeChatAskKey, + showAsk: showNativeChatAsk, + dismissAsk: dismissNativeChatAsk + } = useMobileNativeChatAskDismiss({ + ask: nativeChatAskPrompt, + detectedAsk: nativeChatDetectedAsk, + scopeKey: activeSessionTabId, + sessionKey: activeChatSessionId, + observing: nativeChatAskObservable }) // Every chat write gates on both: the lease proves the input floor is ours, and @@ -232,15 +202,23 @@ export function useMobileNativeChatController(args: { worktreeId }) + // Why: the send seam reports outgoing catalog commands to session-option + // tracking, but the options hook needs the seam's dispatcher — a ref breaks + // the cycle without re-creating the send callbacks per snapshot. + const recordSessionOptionCommandRef = useRef<(command: string) => void>(() => {}) + const { send: handleNativeChatSend, sendWithOutcome: handleNativeChatSendWithOutcome, - answerQuestion: handleNativeChatQuestionAnswer + answerQuestion: handleNativeChatQuestionAnswer, + dispatchCommand: handleNativeChatDispatchCommand } = useMobileNativeChatMessageSend({ client, enabled: inputSendable, handleRef: activeHandleRef, deviceTokenRef, + agentRef: activeChatAgentRef, + commandSendRef: recordSessionOptionCommandRef, captureSendOrigin, readSeededLaunchDraftSeed, clearDraftForSend, @@ -249,6 +227,24 @@ export function useMobileNativeChatController(args: { holdUnconfirmedSend, onSendError }) + + // Bring the terminal view forward when an agent-owned picker command is used. + const handleAgentPicker = useCallback(() => { + if (activeSessionTabId && isTabChatView(activeSessionTabId)) { + toggleTabChatView(activeSessionTabId) + } + }, [activeSessionTabId, isTabChatView, toggleTabChatView]) + + const sessionOptions = useMobileNativeChatSessionOptions({ + agent: activeChatResolution?.agent ?? null, + scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), + reportedModel: activeSessionTab?.agentStatus?.model ?? null, + dispatchCommand: handleNativeChatDispatchCommand, + onAgentPicker: handleAgentPicker + }) + useLayoutEffect(() => { + recordSessionOptionCommandRef.current = sessionOptions.recordCommand + }, [sessionOptions.recordCommand]) // Card actions retire the route's held failure banner too, not just sends. const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved) const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved) @@ -263,6 +259,7 @@ export function useMobileNativeChatController(args: { chatComposerText, setChatComposerText, chatPending, + chatImagePreviewsByMessageId, nativeChatSession, nativeChatAgentWorking, nativeChatAgentStatusLive: connState === 'connected', @@ -272,7 +269,9 @@ export function useMobileNativeChatController(args: { nativeChatStreamScopeKey: streamScopeKey, nativeChatPermission, nativeChatQuestion, - nativeChatAsk, + nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null, + nativeChatAskKey, + dismissNativeChatAsk, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, handleNativeChatRespondPermission: respond, @@ -282,6 +281,10 @@ export function useMobileNativeChatController(args: { handleNativeChatQuestionAnswer, handleNativeChatSend, handleNativeChatSendWithOutcome, - readSeededLaunchDraft + readSeededLaunchDraft, + nativeChatSessionOptions: + sessionOptions.snapshot.length > 0 + ? { controller: sessionOptions, isWorking: nativeChatAgentWorking } + : null } } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index bf3f94bd239..154ef3fb870 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -269,6 +269,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it("keeps an image-only echo when an unrelated text send's echo lands", async () => { @@ -345,6 +346,29 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] }) + }) + + it('hands a marker-only image preview to the authoritative user bubble', async () => { + await mount('a') + const origin = state?.captureSendOrigin('') + act(() => { + if (origin) { + state?.acceptSend(origin, '', ['file:///a.jpg']) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('u1', '[Image #1]')] + }) + ) + ) + + expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it('does not reconcile a repeated send against an older identical turn', async () => { @@ -722,21 +746,59 @@ describe('useMobileNativeChatDrafts', () => { } }) - it('accepts and clears the first send before a provider session id exists', async () => { + it('preserves first-send images through session assignment and transcript replacement', async () => { await mount('a') await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) - act(() => state?.setComposerText('start the session')) + const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + act(() => state?.setComposerText('look')) - const origin = state?.captureSendOrigin('start the session') + const origin = state?.captureSendOrigin('look') expect(origin).toMatchObject({ pendingKey: null }) act(() => { if (origin) { - state?.clearDraftForSend(origin, 'start the session') - state?.acceptSend(origin, 'start the session') + state?.clearDraftForSend(origin, 'look') + state?.acceptSend(origin, 'look', images) } }) expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' })) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]') + ] + }) + ) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]'), + userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look') + ] + }) + ) + ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images }) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a43bc8b286f..9da4dea6a9b 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -3,35 +3,29 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, countUserTextOccurrences, + findLandedImagePreviewEchoes, findLandedUnconfirmedSends, + mergeLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, normalizedUserText, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' +import { + appendMobileNativeChatPending, + combineMobileNativeChatPending, + mergeWaitingSessionPending, + removeWaitingSessionPending, + type MobileNativeChatPendingMessage, + type MobileNativeChatSendOrigin +} from './mobile-native-chat-pending-echo' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' -export type MobileNativeChatPendingMessage = { - id: string - text: string - expectedOccurrence: number - /** Local preview URIs of images ridden along on the send, rendered as thumbnails - * on the echo bubble so the sent photo shows before the transcript catches up. */ - images?: string[] - /** Transcript tail when sent — an image-only echo (no text to match) reconciles - * against new `[Image: source: …]` echo turns after this id, so pagination, - * agent replies, and unrelated text echoes can't clear it early. */ - baselineTailMessageId: string | null -} -export type MobileNativeChatSendOrigin = { - draftKey: string - pendingKey: string | null - normalizedText: string - baselineOccurrences: number - baselineTailMessageId: string | null -} +export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] +const NO_IMAGE_PREVIEWS: Record = {} // How long an ack-lost send waits for its transcript echo before the UI surfaces // that delivery remains unconfirmed. @@ -57,6 +51,9 @@ export function useMobileNativeChatDrafts(args: { composerText: string setComposerText: Dispatch> pending: MobileNativeChatPendingMessage[] + /** Phone-local previews rebound to the transcript message that replaced the + * optimistic echo, keyed by authoritative message id. */ + imagePreviewsByMessageId: Record captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null /** Launch-context text still believed to be parked on the agent's TUI input * line, or null once it has been declined or retired. Send paths size their @@ -91,6 +88,12 @@ export function useMobileNativeChatDrafts(args: { const [pendingBySession, setPendingBySession] = useState< Record >({}) + const [pendingWaitingForSession, setPendingWaitingForSession] = useState< + Record + >({}) + const [imagePreviewsBySession, setImagePreviewsBySession] = useState< + Record> + >({}) const pendingCounterRef = useRef(0) const messagesRef = useRef(messages) messagesRef.current = messages @@ -162,41 +165,21 @@ export function useMobileNativeChatDrafts(args: { const acceptSend = useCallback( (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - // Why: the first prompt can be sent before the provider reports a session - // id; wait for an id before keying an optimistic echo. - if (!origin.pendingKey) { + if (!origin.pendingKey && !images?.length) { return } - const pendingKey = origin.pendingKey pendingCounterRef.current += 1 - setPendingBySession((previous) => { - const current = previous[pendingKey] ?? NO_PENDING_MESSAGES - const earlierOutstanding = current.filter( - (pending) => - pending.text.trim() === origin.normalizedText && - pending.expectedOccurrence > origin.baselineOccurrences - ).length - // An empty-text send reconciles by image-echo ordinal: every outstanding - // send's ridden-along images echo as `[Image: source: …]` turns after - // this send's baseline tail, ahead of this send's own echo. - const expectedImageEchoOrdinal = - current.reduce( - (sum, pending) => - sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)), - 0 - ) + 1 - const pending: MobileNativeChatPendingMessage = { - id: `pending-${pendingCounterRef.current}`, - text, - expectedOccurrence: - origin.normalizedText === '' - ? expectedImageEchoOrdinal - : origin.baselineOccurrences + earlierOutstanding + 1, - baselineTailMessageId: origin.baselineTailMessageId, - ...(images && images.length > 0 ? { images } : {}) - } - return { ...previous, [pendingKey]: [...current, pending] } - }) + const id = `pending-${pendingCounterRef.current}` + const key = origin.pendingKey + if (key) { + setPendingBySession((previous) => + appendMobileNativeChatPending(previous, key, id, origin, text, images) + ) + } else { + setPendingWaitingForSession((previous) => + appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) + ) + } }, [] ) @@ -255,9 +238,7 @@ export function useMobileNativeChatDrafts(args: { const landedSet = new Set(landed) unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) for (const entry of landed) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } }, [messages, draftKey, pendingKey]) @@ -266,21 +247,49 @@ export function useMobileNativeChatDrafts(args: { return () => { mountedRef.current = false for (const entry of unconfirmedRef.current) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } unconfirmedRef.current = [] } }, []) - const pending = pendingKey - ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + const waitingForSession = draftKey + ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES useEffect(() => { - if (!pendingKey || pending.length === 0) { + if (!draftKey || !pendingKey || waitingForSession.length === 0) { return } + const movedIds = new Set(waitingForSession.map((item) => item.id)) + setPendingBySession((previous) => + mergeWaitingSessionPending(previous, pendingKey, waitingForSession) + ) + setPendingWaitingForSession((previous) => + removeWaitingSessionPending(previous, draftKey, movedIds) + ) + }, [draftKey, pendingKey, waitingForSession]) + + const sessionPending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + const pending = combineMobileNativeChatPending(sessionPending, waitingForSession) + useEffect(() => { + if (!pendingKey) { + return + } + setImagePreviewsBySession((previous) => + migrateImagePreviewMessageIds(previous, pendingKey, messages) + ) + if (pending.length === 0) { + return + } + const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending) + const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) + if (landedImagePreviews.length > 0) { + setImagePreviewsBySession((previous) => + mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews) + ) + } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] const landedCounts = new Map() @@ -297,12 +306,20 @@ export function useMobileNativeChatDrafts(args: { // baseline tail — text echoes are excluded so an unrelated outstanding // text send cannot clear it. Ordinal-vs-count stays stable when the effect // re-runs on the shrunken list, and ignores paginated-in history. - const next = current.filter((item) => - item.text.trim() === '' + const next = current.filter((item) => { + if (landedImagePendingIds.has(item.id)) { + return false + } + // Image echoes hand their local URIs to the authoritative message above; + // never drop them through the text-only fallback before that handoff. + if (item.images?.length) { + return true + } + return item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < - item.expectedOccurrence + item.expectedOccurrence : (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence - ) + }) if (next.length === current.length) { return previous } @@ -319,6 +336,9 @@ export function useMobileNativeChatDrafts(args: { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, pending, + imagePreviewsByMessageId: pendingKey + ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) + : NO_IMAGE_PREVIEWS, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 13cb5d96c48..a57e1c7520c 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -11,13 +11,13 @@ import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-im // Fully stub the picker so the real expo/react-native chain never loads under // the vitest transform (react-native ships Flow syntax rolldown can't parse). vi.mock('./mobile-image-source-picker', () => ({ - pickMobileImage: vi.fn(), + pickMobileImages: vi.fn(), ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {} })) -import { pickMobileImage } from './mobile-image-source-picker' +import { pickMobileImages } from './mobile-image-source-picker' -const pick = vi.mocked(pickMobileImage) +const pick = vi.mocked(pickMobileImages) function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'r' } } @@ -124,7 +124,7 @@ describe('useMobileNativeChatImageAttachments', () => { } it('adds an uploaded image as a chip without pasting to the terminal', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) mount( baseArgs({ @@ -145,7 +145,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -208,7 +208,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => { // A single Ctrl+U kills only the last line, so the draft's earlier lines // would survive the clear and ride along with the image as prompt body. - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -242,7 +242,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('spends one budget across the image paste and the text body that follows', async () => { vi.useFakeTimers() try { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -280,7 +280,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -321,7 +321,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and does not submit when the image paste is rejected', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -345,7 +345,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and reports failure when the paste transport throws', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) // No terminal.send responses queued: the clear write throws (dropped transport). const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') @@ -365,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const onSendError = vi.fn() @@ -387,7 +387,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('scopes chips to the tab that attached them', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const args = baseArgs({ client: client as unknown as RpcClient, baseSend }) @@ -426,7 +426,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let firstAttach: Promise | null = null await act(async () => { firstAttach = hook!.attachImage('library') @@ -438,7 +438,7 @@ describe('useMobileNativeChatImageAttachments', () => { expect(hook!.isAttaching).toBe(true) // A concurrent cancelled pick — its finally must leave the counter alone. - pick.mockResolvedValue(null) + pick.mockResolvedValue([]) await act(async () => { await hook!.attachImage('library') }) @@ -454,7 +454,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('clears only the chips that were sent, keeping one attached mid-send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), // first attach @@ -488,7 +488,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(releaseSettle).not.toBeNull() - pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' }) + pick.mockResolvedValue([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) await act(async () => { await hook!.attachImage('library') }) @@ -511,7 +511,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('aborts the send when the active terminal changes during the settle window', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -560,7 +560,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -596,7 +596,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -634,7 +634,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('still heals after the session screen unmounts and remounts (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -688,7 +688,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('retains the stale marker when a rejected healing clear blocks text-only send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -730,7 +730,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('does not reroute text when the active terminal changes during a healing clear', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let releaseClear: ((response: RpcResponse) => void) | null = null const deferredClear = new Promise((resolve) => { releaseClear = resolve @@ -774,7 +774,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('defers the heal instead of burning a rejected clear while the lease is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -818,8 +818,8 @@ describe('useMobileNativeChatImageAttachments', () => { it('heals rejected image submits independently across terminals', async () => { pick - .mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' }) - .mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' }) + .mockResolvedValueOnce([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) + .mockResolvedValueOnce([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) const client = makeClient([ methodNotFound('start-a'), ok('save-a', '/tmp/a.png'), @@ -896,7 +896,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let attach: Promise | null = null await act(async () => { attach = hook!.attachImage('library') diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 38f9e462e46..dbcb97df527 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -5,11 +5,12 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { ImageLibraryPermissionError, - pickMobileImage, + pickMobileImages, type MobileImageSource } from './mobile-image-source-picker' import { - uploadMobileNativeChatImage, + appendPendingNativeChatImages, + uploadMobileNativeChatImages, type PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { @@ -82,10 +83,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - const NO_ATTACHMENTS: PendingNativeChatImage[] = [] function withScopeAttachments( @@ -146,48 +143,53 @@ export function useMobileNativeChatImageAttachments({ // pick or pre-upload error never ran `onUploadStart`, so decrementing the // shared counter would clear a concurrent upload's in-flight flag early. let started = false + const uploadedImages: Omit[] = [] + let uploadError: unknown = null try { - const uploaded = await uploadMobileNativeChatImage(source, { + await uploadMobileNativeChatImages(source, { client, getConnectionId: getActiveWorktreeConnectionId, - pickImage: pickMobileImage, + pickImages: pickMobileImages, + onImageUploaded: (image) => uploadedImages.push(image), onUploadStart: () => { started = true attachingCount.current += 1 setIsAttaching(true) } }) - // Cancelled picker: no error, no toast. - if (!uploaded) { - return - } - idCounter.current += 1 - const chip = { id: `img-${idCounter.current}`, ...uploaded } - setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] })) - onAttachSuccess?.() } catch (error) { + uploadError = error + } finally { + if (started) { + attachingCount.current -= 1 + if (attachingCount.current === 0) { + setIsAttaching(false) + } + } + } + if (uploadedImages.length > 0) { + setAttachmentsByScope((prev) => ({ + ...prev, + [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + })) + onAttachSuccess?.() + } + if (uploadError !== null) { + const message = uploadError instanceof Error ? uploadError.message : String(uploadError) onError?.() if (connStateRef.current !== 'connected') { showToast('Attach failed (disconnected)', 1500) return } - if (error instanceof ImageLibraryPermissionError) { + if (uploadError instanceof ImageLibraryPermissionError) { showToast('Photo permission denied', 1500) return } - if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { showToast('Image too large to attach', 1500) return } showToast('Attach failed', 1500) - } finally { - if (started) { - attachingCount.current -= 1 - if (attachingCount.current <= 0) { - attachingCount.current = 0 - setIsAttaching(false) - } - } } }, [ diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts index fddf9274573..c7ebfe1833a 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' +import type { MobileNativeChatInputLockReason } from './use-mobile-native-chat-control-state' export function useMobileNativeChatInputLease(args: { activeHandle: string | null diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 7c7c8e5814f..0ceda5714da 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -33,23 +33,32 @@ const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' describe('useMobileNativeChatMessageSend', () => { let renderer: ReactTestRenderer | null = null let api: Send | null = null + const acceptSend = vi.fn() + const holdUnconfirmedSend = vi.fn() + const onCommandSend = vi.fn() + const commandSendRef = { current: onCommandSend } + const agentRef = { current: null as string | null } let onSendError = vi.fn() const mount = ( - readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null + readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null, + agent: string | null = 'claude' ): void => { + agentRef.current = agent function Probe(): null { api = useMobileNativeChatMessageSend({ client: { sendRequest: vi.fn() } as never, enabled: true, handleRef: { current: 'term' }, deviceTokenRef: { current: 'device' }, + agentRef, + commandSendRef, captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, readSeededLaunchDraftSeed, clearDraftForSend: () => {}, restoreRejectedDraft: () => {}, - acceptSend: () => {}, - holdUnconfirmedSend: () => {}, + acceptSend, + holdUnconfirmedSend, onSendError }) return null @@ -76,6 +85,10 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + acceptSend.mockReset() + holdUnconfirmedSend.mockReset() + onCommandSend.mockReset() + commandSendRef.current = onCommandSend onSendError = vi.fn() resetMobileNativeChatTerminalWritesForTests() }) @@ -182,6 +195,117 @@ describe('useMobileNativeChatMessageSend', () => { expect(sentArgs().resolvedLaunchDraft).toBeUndefined() }) + it('creates an optimistic echo for an ordinary chat send', async () => { + mount(() => null) + await act(async () => { + await api!.send('hello') + }) + expect(acceptSend).toHaveBeenCalledTimes(1) + expect(onCommandSend).not.toHaveBeenCalled() + }) + + // The STA-3332 "Queued forever" regression: command sends dispatch into the + // agent's TUI and never echo as user turns, so they must not create a pending + // bubble that no transcript match can ever retire. + it('never creates an optimistic echo for a catalog command send', async () => { + mount(() => null) + await act(async () => { + await api!.send('/clear') + }) + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).toHaveBeenCalledWith('/clear') + }) + + it('never creates an optimistic echo for an unknown slash token', async () => { + // `/model` is not in Claude's autocomplete catalog, but the session-option + // recorder still recognizes it without claiming a generic command ran. + mount(() => null) + await act(async () => { + await api!.send('/model sonnet') + }) + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).toHaveBeenCalledWith('/model sonnet') + }) + + it('classifies per agent: /model is a catalog command for Codex', async () => { + mount(() => null, 'codex') + await act(async () => { + await api!.send('/model') + }) + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).toHaveBeenCalledWith('/model') + }) + + it('holds only chat sends for transcript confirmation on a lost ack', async () => { + sendWithOutcome.mockResolvedValue('unknown') + mount(() => null) + await act(async () => { + await api!.send('/clear') + }) + expect(holdUnconfirmedSend).not.toHaveBeenCalled() + await act(async () => { + await api!.send('hello') + }) + expect(holdUnconfirmedSend).toHaveBeenCalledTimes(1) + }) + + it('dispatchCommand surfaces the outcome without echo or composer sync', async () => { + mount(() => ({ text: DRAFT, createdAt: 1 })) + let outcome: string | undefined + await act(async () => { + outcome = await api!.dispatchCommand('/model sonnet') + }) + expect(outcome).toBe('accepted') + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).not.toHaveBeenCalled() + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + }) + + it('binds classification to the agent that started the send', async () => { + let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void + sendWithOutcome.mockReturnValue( + new Promise((resolve) => { + resolveSend = resolve + }) + ) + mount(() => null, 'claude') + let sending!: Promise + act(() => { + sending = api!.send('$skill') + }) + agentRef.current = 'codex' + await act(async () => { + resolveSend('accepted') + await sending + }) + expect(acceptSend).toHaveBeenCalledTimes(1) + }) + + it('records a command against the tab that started the send', async () => { + let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void + sendWithOutcome.mockReturnValue( + new Promise((resolve) => { + resolveSend = resolve + }) + ) + const originalRecorder = vi.fn() + const nextRecorder = vi.fn() + commandSendRef.current = originalRecorder + mount(() => null) + commandSendRef.current = originalRecorder + let sending!: Promise + act(() => { + sending = api!.send('/clear') + }) + commandSendRef.current = nextRecorder + await act(async () => { + resolveSend('accepted') + await sending + }) + expect(originalRecorder).toHaveBeenCalledWith('/clear') + expect(nextRecorder).not.toHaveBeenCalled() + }) + it('rejects a question answer while another composed write holds the terminal', async () => { mount(() => null) // An image paste sequence is mid-flight into the same PTY. diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 7847f83c8e0..1d59e25431c 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -7,6 +7,7 @@ import { type MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' +import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' import { acquireMobileNativeChatTerminalWrite, releaseMobileNativeChatTerminalWrite @@ -29,6 +30,9 @@ export type MobileNativeChatMessageSend = { ) => Promise /** Answer to an agent question — never touches the composer draft. */ answerQuestion: (text: string) => Promise + /** Session-option command dispatch (e.g. `/model sonnet`) — never touches the + * composer draft; callers need the outcome to track dispatched state. */ + dispatchCommand: (text: string) => Promise } /** The native-chat send seam: one write path shared by composer sends, image @@ -38,6 +42,11 @@ export function useMobileNativeChatMessageSend(args: { enabled: boolean handleRef: MutableRefObject deviceTokenRef: MutableRefObject + /** Active tab's agent — classification is per-agent (command catalogs differ). */ + agentRef: MutableRefObject + /** Captured when a control send starts so a later tab switch cannot record its + * session-option effects against the newly active tab. */ + commandSendRef: MutableRefObject<(command: string) => void> captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null /** Launch-context text Orca parked on the agent's TUI input line, or null. Read * at send time so the pre-clear can be sized to every line it occupies. */ @@ -57,6 +66,8 @@ export function useMobileNativeChatMessageSend(args: { enabled, handleRef, deviceTokenRef, + agentRef, + commandSendRef, captureSendOrigin, readSeededLaunchDraftSeed, clearDraftForSend, @@ -71,10 +82,13 @@ export function useMobileNativeChatMessageSend(args: { text: string, images: string[] | undefined, syncComposer: boolean, + recordControlSend: boolean, sharedDeadline?: number ): Promise => { const handle = handleRef.current const origin = captureSendOrigin(text) + const agent = agentRef.current + const recordCommand = commandSendRef.current // Why: the lease collapses one render after `connState`, so a question-card // answer (which reaches this send directly) would otherwise burn the whole // 15s heal+send budget waiting on a socket that is already gone. @@ -165,12 +179,19 @@ export function useMobileNativeChatMessageSend(args: { ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) }) + // Why (desktop parity): a slash/skill send dispatches into the agent's own + // TUI, not the conversation — the transcript never echoes it as a user + // turn, so an optimistic bubble would sit at "Queued" forever and the + // unconfirmed hold could never observe a landing. + const classification = classifyMobileNativeChatSend(agent, text) if (outcome === 'unknown') { - // Why: an ack-lost send usually WAS delivered (issue seen on cellular - // relay) — verify via the transcript echo instead of a false "not sent". - holdUnconfirmedSend(origin, text, () => - onSendError('Delivery unconfirmed — check chat before retrying') - ) + if (classification === 'chat') { + // Why: an ack-lost send usually WAS delivered (issue seen on cellular + // relay) — verify via the transcript echo instead of a false "not sent". + holdUnconfirmedSend(origin, text, () => + onSendError('Delivery unconfirmed — check chat before retrying') + ) + } return 'unknown' } if (outcome === 'rejected') { @@ -180,16 +201,24 @@ export function useMobileNativeChatMessageSend(args: { onSendError('Message not sent') return 'rejected' } - // `images` are local preview URIs for the optimistic echo only — the actual - // image bytes already rode along as a bracketed paste before this text send. - acceptSend(origin, text, images) + if (classification === 'chat') { + // `images` are local preview URIs for the optimistic echo only — the actual + // image bytes already rode along as a bracketed paste before this text send. + acceptSend(origin, text, images) + } else if (recordControlSend) { + // The session-option catalog can recognize controls omitted from the + // autocomplete catalog (for example Claude `/model` and `/fast`). + recordCommand(text.trim()) + } return 'accepted' }, [ acceptSend, + agentRef, captureSendOrigin, clearDraftForSend, client, + commandSendRef, deviceTokenRef, enabled, handleRef, @@ -202,7 +231,7 @@ export function useMobileNativeChatMessageSend(args: { const sendWithOutcome = useCallback( (text: string, images?: string[], deadline?: number) => - sendMessage(text, images, true, deadline), + sendMessage(text, images, true, true, deadline), [sendMessage] ) @@ -226,7 +255,7 @@ export function useMobileNativeChatMessageSend(args: { return false } try { - return (await sendMessage(text, undefined, false)) !== 'rejected' + return (await sendMessage(text, undefined, false, true)) !== 'rejected' } finally { if (terminal) { releaseMobileNativeChatTerminalWrite(terminal) @@ -236,5 +265,25 @@ export function useMobileNativeChatMessageSend(args: { [handleRef, onSendError, sendMessage] ) - return { send, sendWithOutcome, answerQuestion } + // A session-option apply writes to the same input line as a send, and the host + // spaces a send's body and its Enter ~500ms apart — so without this lock an + // apply lands between them and is submitted as part of the user's prompt. + const dispatchCommand = useCallback( + async (text: string): Promise => { + const terminal = handleRef.current + if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { + return 'rejected' + } + try { + return await sendMessage(text, undefined, false, false) + } finally { + if (terminal) { + releaseMobileNativeChatTerminalWrite(terminal) + } + } + }, + [handleRef, sendMessage] + ) + + return { send, sendWithOutcome, answerQuestion, dispatchCommand } } diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts index 413fd1047fd..905b92155e6 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts @@ -2,26 +2,40 @@ import { createElement } from 'react' import TestRenderer from 'react-test-renderer' import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' const APPROVAL = JSON.stringify({ approval: { tool: 'Bash', summary: 'pnpm build > build.log 2>&1' } }) -function permissionFor(status: Partial | null): unknown { - let captured: unknown +const ASK = JSON.stringify({ + questions: [{ question: 'Which path?', options: ['fast', 'safe'] }] +}) + +function promptsFor( + status: Partial | null, + messages: NativeChatMessage[] = [], + transcriptLoading = false +): ReturnType { + let captured: ReturnType | undefined function Probe(): null { captured = useMobileNativeChatPrompts({ enabled: true, status: status as AgentStatusEntry | null, - messages: [] - }).permission + messages, + transcriptLoading + }) return null } TestRenderer.act(() => { TestRenderer.create(createElement(Probe)) }) - return captured + return captured! +} + +function permissionFor(status: Partial | null): unknown { + return promptsFor(status).permission } describe('useMobileNativeChatPrompts approval-envelope state gate', () => { @@ -60,3 +74,86 @@ describe('useMobileNativeChatPrompts approval-envelope state gate', () => { expect(permission?.options.map((o) => o.label)).toEqual(['Yes', 'No']) }) }) + +describe('useMobileNativeChatPrompts ask state gate', () => { + const askMessages: NativeChatMessage[] = [ + { + id: 'm1', + role: 'assistant', + blocks: [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { questions: [{ question: 'Which path?', options: ['fast', 'safe'] }] } + } + ], + timestamp: 0, + source: 'transcript' + } + ] + + it('renders the ask card only while the agent is waiting or blocked', () => { + expect(promptsFor({ state: 'waiting', interactivePrompt: ASK }).ask).toMatchObject({ + questions: [{ question: 'Which path?' }] + }) + expect(promptsFor({ state: 'blocked', interactivePrompt: ASK }).ask).not.toBeNull() + }) + + it('renders no ask card from a sticky prompt while the agent is working or done', () => { + // The prompt payload outlives its answer — same paused gate as permission. + const working = promptsFor({ state: 'working', interactivePrompt: ASK }) + expect(working.ask).toBeNull() + expect(working.detectedAsk).not.toBeNull() + + const done = promptsFor({ state: 'done', interactivePrompt: ASK }) + expect(done.ask).toBeNull() + expect(done.detectedAsk).not.toBeNull() + }) + + it('keeps the transcript-derived pending ask outside the paused gate', () => { + // A hook row idle past AGENT_STATUS_STALE_AFTER_MS projects to `done` with no + // interactivePrompt, so gating this too would make a still-pending question + // unanswerable from mobile. `extractPendingAsk` clears on the tool result. + expect(promptsFor({ state: 'waiting' }, askMessages).ask).not.toBeNull() + expect(promptsFor({ state: 'done' }, askMessages).ask).not.toBeNull() + expect(promptsFor({ state: 'working' }, askMessages).ask).not.toBeNull() + expect(promptsFor(null, askMessages).ask).not.toBeNull() + }) + + it('withholds retained transcript asks while the replacement read is unsettled', () => { + const prompts = promptsFor({ state: 'done' }, askMessages, true) + expect(prompts.ask).toBeNull() + expect(prompts.detectedAsk).toBeNull() + }) + + it('keeps a paused live status ask authoritative while the read is unsettled', () => { + const prompts = promptsFor({ state: 'waiting', interactivePrompt: ASK }, askMessages, true) + expect(prompts.ask).toMatchObject({ questions: [{ question: 'Which path?' }] }) + expect(prompts.detectedAsk).not.toBeNull() + }) + + it('does not leak a paused-out sticky status prompt through the transcript fallback', () => { + // The post-answer window: the status still carries the prompt while flipping + // to `working`, and the transcript's tool-result row has not landed yet, so + // both sources still describe the answered question. The paused gate only + // holds because a status prompt suppresses the transcript fallback outright. + const working = promptsFor({ state: 'working', interactivePrompt: ASK }, askMessages) + expect(working.ask).toBeNull() + expect(working.detectedAsk).not.toBeNull() + }) + + it('still refuses an unpaused sticky status prompt that the transcript does not back', () => { + const answered: NativeChatMessage[] = [ + ...askMessages, + { + id: 'm2', + role: 'tool', + blocks: [{ type: 'tool-result', output: 'fast' }], + timestamp: 1, + source: 'transcript' + } + ] + expect(promptsFor({ state: 'done', interactivePrompt: ASK }, answered).ask).toBeNull() + expect(promptsFor({ state: 'done' }, answered).ask).toBeNull() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 1e362030750..61b831dafd2 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -1,13 +1,14 @@ import { useMemo } from 'react' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask' +import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask' import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' export type MobileNativeChatPrompts = { permission: ReturnType question: ReturnType + detectedAsk: ReturnType ask: ReturnType } @@ -16,8 +17,11 @@ export function useMobileNativeChatPrompts(args: { enabled: boolean status: AgentStatusEntry | null | undefined messages: readonly NativeChatMessage[] + /** True while `messages` is an unsettled read (including the cached list held + * across a reconnect). Required: an ask derived from it may already be answered. */ + transcriptLoading: boolean }): MobileNativeChatPrompts { - const { enabled, status, messages } = args + const { enabled, status, messages, transcriptLoading } = args const blocked = status?.state === 'waiting' || status?.state === 'blocked' // Both permission paths sit inside the paused gate: an approval envelope can // outlive its answer (the host keeps it sticky), so only a waiting/blocked @@ -37,14 +41,27 @@ export function useMobileNativeChatPrompts(args: { () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), [status?.interactivePrompt, status?.toolName] ) - const askFromMessages = useMemo( - () => (askFromStatus ? null : extractPendingAsk(messages)), - [askFromStatus, messages] + const resolvedAsk = useMemo( + () => + resolveNativeChatAsk({ + liveAsk: askFromStatus, + messages, + transcriptSettled: !transcriptLoading + }), + [askFromStatus, transcriptLoading, messages] ) + const askFromMessages = askFromStatus ? null : resolvedAsk + const detectedAsk = askFromStatus ?? askFromMessages return { permission, question, - ask: enabled ? (askFromStatus ?? askFromMessages) : null + detectedAsk: enabled ? detectedAsk : null, + // Only the status payload needs the paused gate the approval envelope uses: + // it outlives its answer, so a working/done agent must not surface one. The + // transcript fallback clears itself when the tool result lands, and it is the + // only source left once the hook row goes stale and projects to `done` with + // no interactivePrompt — gating it too strands a genuinely pending question. + ask: enabled ? ((blocked ? askFromStatus : null) ?? askFromMessages) : null } } diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts new file mode 100644 index 00000000000..246c3021948 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -0,0 +1,279 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { + clearMobileSessionOptionRecordsForTests, + useMobileNativeChatSessionOptions, + type MobileNativeChatSessionOptionsController +} from './use-mobile-native-chat-session-options' + +type HookArgs = Parameters[0] + +describe('useMobileNativeChatSessionOptions', () => { + let renderer: ReactTestRenderer | null = null + let api: MobileNativeChatSessionOptionsController | null = null + let hookArgs: HookArgs + const dispatchCommand = vi.fn<(command: string) => Promise>() + const onAgentPicker = vi.fn() + + function Probe(): null { + api = useMobileNativeChatSessionOptions(hookArgs) + return null + } + + const mount = (overrides: Partial = {}): void => { + hookArgs = { + agent: 'claude', + scopeKey: 'host\0worktree\0tab', + reportedModel: null, + dispatchCommand, + onAgentPicker, + ...overrides + } + act(() => { + renderer = create(createElement(Probe)) + }) + } + + const update = (overrides: Partial): void => { + hookArgs = { ...hookArgs, ...overrides } + act(() => { + renderer!.update(createElement(Probe)) + }) + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + clearMobileSessionOptionRecordsForTests() + dispatchCommand.mockReset() + dispatchCommand.mockResolvedValue('accepted') + onAgentPicker.mockReset() + }) + afterEach(() => { + act(() => { + renderer?.unmount() + }) + renderer = null + api = null + }) + + it('serves the shared catalog snapshot for the agent', () => { + mount() + expect(api!.snapshot[0]).toMatchObject({ id: 'model', category: 'model' }) + expect(api!.snapshot[0]!.kind).toMatchObject({ type: 'select' }) + }) + + it('returns an empty snapshot for agents without a catalog', () => { + mount({ agent: 'amp' }) + expect(api!.snapshot).toEqual([]) + }) + + it('does not expose catalog-backed agents outside the Claude and Codex scope', () => { + mount({ agent: 'gemini' }) + expect(api!.snapshot).toEqual([]) + }) + + it('applies a model pick through the catalog modelApply command', async () => { + mount() + let applied: boolean | undefined + await act(async () => { + applied = await api!.setOption('model', 'opus') + }) + expect(applied).toBe(true) + expect(dispatchCommand).toHaveBeenCalledWith('/model opus') + const model = api!.snapshot[0]! + expect(model).toMatchObject({ valueSource: 'dispatched' }) + expect(model.kind).toMatchObject({ currentValue: 'opus' }) + }) + + it('keeps tracked truth when the dispatch is rejected', async () => { + dispatchCommand.mockResolvedValue('rejected') + mount() + let applied: boolean | undefined + await act(async () => { + applied = await api!.setOption('model', 'opus') + }) + expect(applied).toBe(false) + expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('applies Codex model changes through the native command', async () => { + mount({ agent: 'codex' }) + expect(api!.snapshot[0]?.action).toBeUndefined() + await act(async () => { + await api!.setOption('model', 'gpt-5.5') + }) + expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5') + expect(onAgentPicker).not.toHaveBeenCalled() + }) + + it('seeds the current model from a hook-reported provider model', () => { + mount({ reportedModel: 'claude-sonnet-5' }) + const model = api!.snapshot[0]! + expect(model).toMatchObject({ valueSource: 'reported' }) + expect(model.kind).toMatchObject({ currentValue: 'sonnet' }) + }) + + it('tracks typed commands via recordCommand', () => { + mount() + act(() => { + api!.recordCommand('/model haiku') + }) + expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'haiku' }) + expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' }) + }) + + it('applies an option under the tracked model and scopes it to that model', async () => { + mount({ reportedModel: 'claude-sonnet-5' }) + await act(async () => { + await api!.setOption('effort', 'low') + }) + expect(dispatchCommand).toHaveBeenCalledWith('/effort low') + const effort = api!.snapshot.find((descriptor) => descriptor.id === 'effort') + expect(effort).toMatchObject({ valueSource: 'dispatched' }) + expect(effort!.kind).toMatchObject({ currentValue: 'low' }) + }) + + it('does not file an option under a model that changed mid-dispatch', async () => { + const resolvers: ((outcome: MobileNativeChatSendOutcome) => void)[] = [] + dispatchCommand.mockImplementation( + () => new Promise((resolve) => resolvers.push(resolve)) + ) + mount({ reportedModel: 'claude-sonnet-5' }) + let applied!: Promise + await act(async () => { + applied = api!.setOption('effort', 'low') + await Promise.resolve() + }) + // A report lands while `/effort low` is still in flight and moves the model. + update({ reportedModel: 'claude-opus-5' }) + await act(async () => { + resolvers[0]!('accepted') + await applied + }) + // The effort must not be recorded against Opus — it was sent for Sonnet. + const effort = api!.snapshot.find((descriptor) => descriptor.id === 'effort') + expect(effort?.kind).not.toMatchObject({ currentValue: 'low' }) + }) + + it('does not revive a stale session-start report over a newer local pick', async () => { + mount({ reportedModel: 'claude-sonnet-5' }) + await act(async () => { + await api!.setOption('model', 'opus') + }) + expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' }) + // Leaving the tab and returning re-delivers the SAME session-start report, + // which cannot have observed the `/model opus` sent after it. + update({ scopeKey: 'host\0worktree\0other' }) + update({ scopeKey: 'host\0worktree\0tab' }) + expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' }) + expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' }) + }) + + it('still lets a genuinely new report supersede a local pick', async () => { + mount({ reportedModel: 'claude-sonnet-5' }) + await act(async () => { + await api!.setOption('model', 'opus') + }) + update({ reportedModel: 'claude-haiku-4-5' }) + expect(api!.snapshot[0]).toMatchObject({ valueSource: 'reported' }) + expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'haiku' }) + }) + + it('keeps the live tab’s tracked model when other tabs overflow the record cap', async () => { + mount() + await act(async () => { + await api!.setOption('model', 'opus') + }) + // Far more scopes than the cap, revisiting the live tab in between the way a + // chat↔terminal flip does — insertion-order eviction would shed it. + for (let index = 0; index < 40; index += 1) { + update({ scopeKey: `host\0worktree\0overflow-${index}` }) + update({ scopeKey: 'host\0worktree\0tab' }) + } + expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' }) + expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' }) + }) + + it('keeps the latest queued operation pending until it settles', async () => { + const resolvers: ((outcome: MobileNativeChatSendOutcome) => void)[] = [] + dispatchCommand.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }) + ) + mount({ reportedModel: 'claude-sonnet-5' }) + let first!: Promise + let second!: Promise + await act(async () => { + first = api!.setOption('effort', 'low') + second = api!.setOption('model', 'opus') + await Promise.resolve() + }) + expect(api!.pendingId).toBe('model') + await act(async () => { + resolvers[0]!('accepted') + await Promise.resolve() + }) + expect(dispatchCommand).toHaveBeenCalledTimes(2) + expect(api!.pendingId).toBe('model') + await act(async () => { + resolvers[1]!('accepted') + await Promise.all([first, second]) + }) + expect(api!.pendingId).toBeNull() + }) + + it('does not dispatch a queued option into a newly active tab', async () => { + let resolveFirst!: (outcome: MobileNativeChatSendOutcome) => void + dispatchCommand.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + mount({ reportedModel: 'claude-sonnet-5' }) + let first!: Promise + let queued!: Promise + await act(async () => { + first = api!.setOption('effort', 'low') + queued = api!.setOption('model', 'opus') + await Promise.resolve() + }) + update({ scopeKey: 'host\0worktree\0other-tab' }) + await act(async () => { + resolveFirst('accepted') + await first + }) + await expect(queued).resolves.toBe(false) + expect(dispatchCommand).toHaveBeenCalledTimes(1) + }) + + it('does not dispatch queued work after unmount', async () => { + let resolveFirst!: (outcome: MobileNativeChatSendOutcome) => void + dispatchCommand.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + mount({ reportedModel: 'claude-sonnet-5' }) + let first!: Promise + let queued!: Promise + await act(async () => { + first = api!.setOption('effort', 'low') + queued = api!.setOption('model', 'opus') + await Promise.resolve() + }) + act(() => { + renderer!.unmount() + }) + renderer = null + resolveFirst('accepted') + await expect(first).resolves.toBe(true) + await expect(queued).resolves.toBe(false) + expect(dispatchCommand).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts new file mode 100644 index 00000000000..be6c5b2ec20 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -0,0 +1,342 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + getAgentSessionOptionCatalog, + type AgentSessionOptionCatalog, + type CatalogModel +} from '../../../src/shared/agent-session-option-catalog' +import type { + SessionOptionDescriptor, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { + buildNativeChatSessionOptionCommand, + recordNativeChatSessionOptionCommand +} from '../../../src/shared/native-chat-session-option-commands' +import { + buildNativeChatSessionOptionSnapshot, + withTrackedNativeChatModel +} from '../../../src/shared/native-chat-session-option-snapshot' +import { + applyNativeChatReportedSessionOptions, + clearNativeChatSessionModel, + createNativeChatSessionOptionRecord, + getTrackedSessionOption, + isFlipOnlyMidSession, + matchNativeChatCatalogModelId, + setTrackedSessionOption, + type NativeChatSessionOptionRecord +} from '../../../src/shared/native-chat-session-option-state' + +export type MobileNativeChatSessionOptionsController = { + /** Model descriptor first, then the current model's options; empty when the + * agent has no catalog. */ + snapshot: SessionOptionDescriptor[] + /** Descriptor id with a dispatch in flight; the UI disables rows meanwhile. */ + pendingId: string | null + setOption: (id: string, value: SessionOptionValue) => Promise + invokeAction: (id: string) => Promise + /** Track a slash command the user typed themselves (e.g. `/model sonnet`). */ + recordCommand: (command: string) => void +} + +type PendingOperation = { id: string; token: number } + +// Why: per-tab records survive chat↔terminal flips and remounts, like desktop's +// scope cache. Bounded so long sessions across many tabs can't grow unbounded. +const MOBILE_SESSION_OPTION_RECORD_CAP = 32 +const recordsByScope = new Map() +// The catalog model id last taken from a hook report, per scope. Mobile cannot +// read the agent's screen, so a repeat of the same report is not new evidence. +const appliedReportByScope = new Map() + +function getScopedRecord(scopeKey: string, agent: string): NativeChatSessionOptionRecord { + const existing = recordsByScope.get(scopeKey) + const record = + existing && existing.agent === agent ? existing : createNativeChatSessionOptionRecord(agent) + if (record !== existing) { + appliedReportByScope.delete(scopeKey) + } + // Why: delete-then-set on every read makes the touched scope most-recent, so + // eviction only sheds the oldest UNTOUCHED tab. Insertion order alone would let + // a long-lived active tab be the oldest key and lose its tracked model. + recordsByScope.delete(scopeKey) + recordsByScope.set(scopeKey, record) + while (recordsByScope.size > MOBILE_SESSION_OPTION_RECORD_CAP) { + const oldest = recordsByScope.keys().next().value + if (oldest === undefined) { + break + } + recordsByScope.delete(oldest) + appliedReportByScope.delete(oldest) + } + return record +} + +export function clearMobileSessionOptionRecordsForTests(): void { + recordsByScope.clear() + appliedReportByScope.clear() +} + +const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = [] + +/** The model list every consumer must see: the catalog's, plus the tracked model + * when the catalog no longer lists it. Desktop reconciles identically. */ +function activeModels( + catalog: AgentSessionOptionCatalog, + record: NativeChatSessionOptionRecord +): CatalogModel[] { + return withTrackedNativeChatModel(catalog, catalog.models, record) +} + +export function useMobileNativeChatSessionOptions(args: { + agent: string | null + /** Stable per-tab scope (host + worktree + tab), or null when no tab is active. */ + scopeKey: string | null + /** Provider model from live agent status, when the hook reported one. */ + reportedModel: string | null + dispatchCommand: (command: string) => Promise + /** A model change that must happen in the agent's own TUI picker was + * dispatched — bring the terminal view forward. */ + onAgentPicker?: () => void +}): MobileNativeChatSessionOptionsController { + const { agent, scopeKey, reportedModel, dispatchCommand, onAgentPicker } = args + const catalog = useMemo( + () => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null), + [agent] + ) + const identity = agent && scopeKey ? `${scopeKey}\0${agent}` : null + const [version, setVersion] = useState(0) + const [pendingByIdentity, setPendingByIdentity] = useState< + Record + >({}) + const pendingId = identity ? (pendingByIdentity[identity]?.id ?? null) : null + const bump = useCallback(() => setVersion((current) => current + 1), []) + const activeIdentityRef = useRef(identity) + const applyQueuesRef = useRef(new Map>()) + const operationTokenRef = useRef(0) + + useLayoutEffect(() => { + activeIdentityRef.current = identity + return () => { + activeIdentityRef.current = null + } + }, [identity]) + + // Seed the current model from live agent status; hook reports are authority + // over locally dispatched guesses (desktop 'reported' source parity). + useEffect(() => { + if (!catalog || !scopeKey || !agent || !reportedModel) { + return + } + const matched = matchNativeChatCatalogModelId(catalog, reportedModel) + if (!matched) { + return + } + // Why: the same report is re-delivered whenever the tab is re-entered or the + // status stream reconnects, and a session-start report cannot have observed a + // `/model` sent after it. Re-applying it would revert the user's pick. Only a + // report that CHANGES is evidence; the value itself still wins when it does. + if (appliedReportByScope.get(scopeKey) === matched) { + return + } + appliedReportByScope.set(scopeKey, matched) + const record = getScopedRecord(scopeKey, agent) + if (applyNativeChatReportedSessionOptions(record, { model: matched })) { + bump() + } + }, [agent, bump, catalog, reportedModel, scopeKey]) + + const snapshot = useMemo(() => { + if (!catalog || !scopeKey || !agent) { + return EMPTY_SNAPSHOT + } + // Why: `version` invalidates this memo after in-place record mutations. + void version + const record = getScopedRecord(scopeKey, agent) + return buildNativeChatSessionOptionSnapshot({ + catalog, + // The snapshot no longer self-heals an unlisted tracked model; every caller + // reconciles it in, so a value the seed dropped keeps its row and options. + models: activeModels(catalog, record), + record, + mode: 'live', + modelLabel: 'Model' + }) + }, [agent, catalog, scopeKey, version]) + + const runSerialized = useCallback( + (operationIdentity: string, id: string, run: () => Promise): Promise => { + const previous = applyQueuesRef.current.get(operationIdentity) ?? Promise.resolve() + const runIfCurrent = (): Promise => + activeIdentityRef.current === operationIdentity ? run() : Promise.resolve(false) + const chained = previous.then(runIfCurrent, runIfCurrent) + const tail = chained.then( + () => undefined, + () => undefined + ) + applyQueuesRef.current.set(operationIdentity, tail) + operationTokenRef.current += 1 + const token = operationTokenRef.current + setPendingByIdentity((current) => ({ + ...current, + [operationIdentity]: { id, token } + })) + void tail.then(() => { + if (applyQueuesRef.current.get(operationIdentity) === tail) { + applyQueuesRef.current.delete(operationIdentity) + } + setPendingByIdentity((current) => { + if (current[operationIdentity]?.token !== token) { + return current + } + const next = { ...current } + delete next[operationIdentity] + return next + }) + }) + return chained + }, + [] + ) + + const setOption = useCallback( + (id: string, value: SessionOptionValue): Promise => { + if (!catalog || !scopeKey || !agent || !identity) { + return Promise.resolve(false) + } + return runSerialized(identity, id, async () => { + const record = getScopedRecord(scopeKey, agent) + const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null + const apply = + id === 'model' + ? catalog.modelApply + : activeModels(catalog, record) + .find((model) => model.id === previousModelId) + ?.options.find((option) => option.id === id)?.apply + if (!apply || apply.midSession?.kind === 'agent-picker') { + return false + } + const flipOnly = isFlipOnlyMidSession(apply.midSession) + const trackedToggle = flipOnly + ? getTrackedSessionOption(record, previousModelId, id) + : undefined + if (flipOnly && !trackedToggle) { + // Why: a flip from an unknown baseline cannot honor an absolute target. + return false + } + // Why: same absolute target must never re-dispatch a flip (would invert the agent). + if (flipOnly && trackedToggle?.value === value) { + return true + } + const command = buildNativeChatSessionOptionCommand({ + optionId: id, + value, + apply, + modelId: previousModelId, + catalog, + models: activeModels(catalog, record), + record + }) + if (!command) { + return false + } + // Baseline for detecting a hook report or typed command that lands while + // the dispatch is in flight — the record is shared mutable state and the + // report effect is not on this queue. + const trackedBeforeDispatch = + id === 'model' ? undefined : getTrackedSessionOption(record, previousModelId, id) + const outcome = await dispatchCommand(command) + if (outcome === 'rejected') { + return false + } + if (id !== 'model') { + // Why (desktop parity): `setTrackedSessionOption` resolves the owning + // model when it commits, not when the command was built. If the model + // moved during the dispatch, committing now would file this value under + // the NEW model — claiming an effort the agent was never asked for. + const modelStill = typeof record.model?.value === 'string' ? record.model.value : null + if ( + modelStill !== previousModelId || + getTrackedSessionOption(record, previousModelId, id) !== trackedBeforeDispatch + ) { + return true + } + } + if (id === 'model') { + if (typeof value === 'string' && previousModelId !== value) { + // Why: switching models can reset effort/toggles for the destination model. + delete record.valuesByModel[value] + } + } + // Why: flip-only never heals via agent report — track as applied best-known. + setTrackedSessionOption(record, id, value, flipOnly ? 'applied' : 'dispatched') + bump() + return true + }) + }, + [agent, bump, catalog, dispatchCommand, identity, runSerialized, scopeKey] + ) + + const invokeAction = useCallback( + (id: string): Promise => { + if (!catalog || !scopeKey || !agent || !identity) { + return Promise.resolve(false) + } + return runSerialized(identity, id, async () => { + const record = getScopedRecord(scopeKey, agent) + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + const apply = + id === 'model' + ? catalog.modelApply + : activeModels(catalog, record) + .find((model) => model.id === modelId) + ?.options.find((option) => option.id === id)?.apply + const midSession = apply?.midSession + if (midSession?.kind === 'agent-picker') { + const outcome = await dispatchCommand(midSession.command) + if (outcome === 'rejected') { + return false + } + clearNativeChatSessionModel(record) + bump() + onAgentPicker?.() + return true + } + if (isFlipOnlyMidSession(midSession) && !getTrackedSessionOption(record, modelId, id)) { + // Why: an unknown baseline remains unknown after one inversion. + return (await dispatchCommand(midSession.command)) !== 'rejected' + } + return false + }) + }, + [agent, bump, catalog, dispatchCommand, identity, onAgentPicker, runSerialized, scopeKey] + ) + + const recordCommand = useCallback( + (command: string): void => { + if (!catalog || !scopeKey || !agent) { + return + } + const record = getScopedRecord(scopeKey, agent) + const result = recordNativeChatSessionOptionCommand({ + catalog, + models: activeModels(catalog, record), + record, + command + }) + if (result.changed) { + bump() + } + if (result.opensAgentPicker) { + onAgentPicker?.() + } + }, + [agent, bump, catalog, onAgentPicker, scopeKey] + ) + + return useMemo( + () => ({ snapshot, pendingId, setOption, invokeAction, recordCommand }), + [snapshot, pendingId, setOption, invokeAction, recordCommand] + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 844c311b827..8c685d00202 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -36,6 +36,7 @@ describe('useMobileNativeChatSession', () => { function Harness({ client }: { client: RpcClient | null }): null { state = useMobileNativeChatSession({ client, + sourceIdentity: 'host-a\0workspace-a', agent: 'claude', sessionId: 'session', transcriptPath: null @@ -456,14 +457,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => { function Harness({ client, sessionId, - agent = 'claude' + agent = 'claude', + sourceIdentity = 'host-a\0workspace-a' }: { client: RpcClient | null sessionId: string | null agent?: string | null + sourceIdentity?: string }): null { const session = useMobileNativeChatSession({ client, + sourceIdentity, agent, sessionId, transcriptPath: null @@ -506,7 +510,8 @@ describe('useMobileNativeChatSession transcriptLoading', () => { it('re-reads instead of resurfacing a settled read when the same identity returns', async () => { // Leaving chat view nulls the agent, then returning restores the identity a // settled read already matched — but its list was cleared, so trusting it - // would report 'ready' over an empty transcript. + // would report 'ready' over an empty transcript. The last settled list for + // this identity keeps rendering while the re-read is in flight. const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) return () => {} @@ -524,12 +529,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => { renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' })) ) - expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + expect(renders[0]).toMatchObject({ + status: 'loading', + transcriptLoading: true, + ids: ['a-1'] + }) }) - it('re-reads instead of resurfacing a settled read after a reconnect', async () => { - // A reconnect swaps the client without moving the identity; the effect - // re-subscribes and clears the list, so the old outcome must not stand. + it('keeps the last settled list rendered while a swapped client re-reads', async () => { + // A manual-retry reconnect swaps the client without moving the identity; the + // old outcome must not stand ('loading', not 'ready'), but the cached + // transcript keeps rendering instead of collapsing to a full-screen spinner. const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) return () => {} @@ -538,13 +548,61 @@ describe('useMobileNativeChatSession transcriptLoading', () => { await mountAt(client, 'session-a') expect(renders.at(-1)).toMatchObject({ status: 'ready' }) - const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + let emitFresh: (frame: unknown) => void = () => {} + const reconnected = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + emitFresh = onData + return () => {} + }) + } as unknown as RpcClient renders.length = 0 await act(async () => renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' })) ) - expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + expect(renders[0]).toMatchObject({ + status: 'loading', + transcriptLoading: true, + ids: ['a-1'] + }) + // Every commit of the window, not just the first: the re-subscribe lands a + // commit after it, so clearing the cache there blanks the transcript the + // user actually sees while leaving a first-frame assertion green. + expect([...new Set(renders.map((entry) => entry.ids.join(',')))]).toEqual(['a-1']) + + // The fresh client's snapshot supersedes the held list. + await act(async () => + emitFresh({ type: 'snapshot', messages: [message('a-1'), message('a-2')], hasMore: false }) + ) + expect(renders.at(-1)).toMatchObject({ + status: 'ready', + transcriptLoading: false, + ids: ['a-1', 'a-2'] + }) + }) + + it('never holds a cached list across a host/workspace source change', async () => { + const firstClient = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + onData({ type: 'snapshot', messages: [message('source-a')], hasMore: false }) + return () => {} + }) + } as unknown as RpcClient + await mountAt(firstClient, 'session-a') + + const secondClient = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + renders.length = 0 + await act(async () => + renderer?.update( + createElement(Harness, { + client: secondClient, + sessionId: 'session-a', + sourceIdentity: 'host-b\0workspace-b' + }) + ) + ) + + expect(renders[0]).toMatchObject({ status: 'loading', ids: [] }) }) it('never hands out the previous session’s messages under the new session id', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 56db7def584..6305d0418d9 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -1,4 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { + createNativeChatTranscriptRetention, + encodeNativeChatTranscriptIdentity +} from '../../../src/shared/native-chat-transcript-retention' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' @@ -28,9 +32,6 @@ export type MobileNativeChatSession = { loadEarlier: () => void } -// Stable empty reference so a not-yet-current read doesn't churn consumers. -const EMPTY_MESSAGES: NativeChatMessage[] = [] - // Small first page for a fast first paint; grows by a page as the user scrolls. const INITIAL_LIMIT = 40 const PAGE = 60 @@ -45,13 +46,20 @@ type ReadSessionResult = * an ordered tail); live appends merge by id so order stays stable. */ export function useMobileNativeChatSession(args: { client: RpcClient | null + /** Stable host/workspace source; unlike `client`, it survives manual reconnect. */ + sourceIdentity: string agent: string | null sessionId: string | null transcriptPath: string | null }): MobileNativeChatSession { - const { client, agent, sessionId, transcriptPath } = args + const { client, sourceIdentity, agent, sessionId, transcriptPath } = args const [messages, setMessages] = useState([]) - const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}` + const identity = encodeNativeChatTranscriptIdentity([ + sourceIdentity, + agent, + sessionId, + transcriptPath + ]) // Pre-read status is a pure function of the props, so derive it rather than // letting the effect write it a commit later. const initialStatus: MobileNativeChatStatus = @@ -95,6 +103,13 @@ export function useMobileNativeChatSession(args: { // Whether this subscription already delivered its base snapshot; later // snapshots on the same subscription are reconnect replays, not fresh bases. const snapshotSeenRef = useRef(false) + const transcriptRetentionRef = useRef(createNativeChatTranscriptRetention()) + const settledReady = settled?.status === 'ready' + useEffect(() => { + if (settledReady) { + transcriptRetentionRef.current.capture(identity, messages) + } + }, [identity, messages, settledReady]) // Replace the base list (read results are an ordered tail). Resets the merger // cache so the index is rebuilt once over the new base. @@ -258,10 +273,17 @@ export function useMobileNativeChatSession(args: { })() }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + const visibleMessages = transcriptRetentionRef.current.visible({ + identity, + messages, + settled: settledReady, + loading: status === 'loading' + }) + return { // Withheld until the settled read belongs to this identity: the effect that // clears the previous tab's list is passive, so `messages` lags a commit. - messages: settled ? messages : EMPTY_MESSAGES, + messages: visibleMessages, status, transcriptLoading: status === 'loading', error, diff --git a/mobile/src/source-control/MobileGitHistoryList.test.tsx b/mobile/src/source-control/MobileGitHistoryList.test.tsx new file mode 100644 index 00000000000..50b0fa7fe73 --- /dev/null +++ b/mobile/src/source-control/MobileGitHistoryList.test.tsx @@ -0,0 +1,168 @@ +import { createElement, type ReactElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { MobileGitHistoryList } from './MobileGitHistoryList' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + FlatList: ({ + data, + renderItem + }: { + data: { id: string }[] + renderItem: (info: { item: { id: string } }) => ReactElement + }) => + createElement( + 'FlatList', + null, + data.map((item) => createElement('Row', { key: item.id }, renderItem({ item }))) + ), + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ ChevronDown: 'ChevronDown', ChevronRight: 'ChevronRight' })) +vi.mock('../transport/client-context', () => ({ useForceReconnect: () => vi.fn() })) + +function historyResponse(subject: string) { + return { + ok: true, + result: { + items: [{ id: 'commit-1', displayId: 'c0mm1t1', subject, author: 'Ada', parentIds: [] }] + } + } +} + +const compareResponse = { + ok: true, + result: { entries: [{ path: 'src/app.ts', added: 3, removed: 1 }] } +} + +describe('MobileGitHistoryList', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function listElement(client: RpcClient | null, connState: ConnectionState) { + return createElement(MobileGitHistoryList, { + client, + connState, + worktreeId: 'wt-1', + hostId: 'host-1', + bottomInset: 0 + }) + } + + async function render(client: RpcClient, connState: ConnectionState): Promise { + await act(async () => { + renderer = create(listElement(client, connState)) + await Promise.resolve() + }) + } + + async function update(client: RpcClient | null, connState: ConnectionState): Promise { + await act(async () => { + renderer?.update(listElement(client, connState)) + await Promise.resolve() + }) + } + + function tree(): string { + return JSON.stringify(renderer?.toJSON()) + } + + it('keeps loaded commits visible across a disconnect and its reconnect refetch', async () => { + let releaseRefetch: (() => void) | null = null + const sendRequest = vi + .fn() + .mockResolvedValueOnce(historyResponse('first load')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRefetch = () => resolve(historyResponse('after reconnect')) + }) + ) + const client = { sendRequest } as unknown as RpcClient + + await render(client, 'connected') + expect(tree()).toContain('first load') + + await update(client, 'reconnecting') + expect(tree()).toContain('first load') + + // The refetch is in flight: old rows must stay up instead of flashing empty. + await update(client, 'connected') + expect(tree()).toContain('first load') + + await act(async () => { + releaseRefetch?.() + await Promise.resolve() + }) + expect(tree()).toContain('after reconnect') + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('wipes commits when the worktree identity changes', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce(historyResponse('worktree one')) + .mockReturnValueOnce(new Promise(() => {})) + const client = { sendRequest } as unknown as RpcClient + + await render(client, 'connected') + expect(tree()).toContain('worktree one') + + await act(async () => { + renderer?.update( + createElement(MobileGitHistoryList, { + client, + connState: 'connected', + worktreeId: 'wt-2', + hostId: 'host-1', + bottomInset: 0 + }) + ) + }) + expect(tree()).not.toContain('worktree one') + }) + + it('refetches the expanded commit files after a reconnect instead of caching the outage answer', async () => { + const sendRequest = vi.fn().mockImplementation((method: string) => { + if (method === 'git.history') { + return Promise.resolve(historyResponse('expandable')) + } + return Promise.resolve(compareResponse) + }) + const client = { sendRequest } as unknown as RpcClient + + await render(client, 'connected') + await update(client, 'disconnected') + + const row = renderer?.root.findAll( + (node) => node.type === 'Pressable' && node.props.onPress !== undefined + )[0] + await act(async () => { + row?.props.onPress() + }) + // Offline expand cannot request anything, so nothing is cached as "no file changes". + expect(tree()).toContain('Waiting for desktop...') + expect(sendRequest).toHaveBeenCalledTimes(1) + + await update(client, 'connected') + expect(sendRequest).toHaveBeenCalledWith('git.commitCompare', { + worktree: 'id:wt-1', + commitId: 'commit-1' + }) + expect(tree()).toContain('src/app.ts') + }) +}) diff --git a/mobile/src/source-control/MobileGitHistoryList.tsx b/mobile/src/source-control/MobileGitHistoryList.tsx index d88fae540d8..dd1a626fdfb 100644 --- a/mobile/src/source-control/MobileGitHistoryList.tsx +++ b/mobile/src/source-control/MobileGitHistoryList.tsx @@ -43,14 +43,14 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({ const [expanded, setExpanded] = useState(null) const [filesById, setFilesById] = useState>({}) - // Worktree identity change must wipe history immediately — even while + // Host or worktree identity change must wipe history immediately — even while // disconnected — so a kept-mounted hub segment never shows another tree's commits. useEffect(() => { setRows(null) setError(null) setExpanded(null) setFilesById({}) - }, [worktreeId]) + }, [hostId, worktreeId]) useEffect(() => { let active = true @@ -59,12 +59,9 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({ // resolveMobileHistoryScreenView keeps them visible (STA-1511). return } - // Reset prior error/rows so a successful retry doesn't stay stuck behind a - // stale error (error wins render precedence). + // Why (F10): clear only the error (it wins render precedence, so a stale one would outlive a + // successful retry) — the loaded rows stay up until fresh ones land instead of flashing empty. setError(null) - setRows(null) - setExpanded(null) - setFilesById({}) void (async () => { try { const result = await fetchMobileGitHistory(client, worktreeId) @@ -95,45 +92,43 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({ setReloadNonce((n) => n + 1) }, [connState, forceReconnect, hostId]) - const toggleCommit = useCallback( - (row: MobileCommitRow) => { - const next = expanded === row.id ? null : row.id - setExpanded(next) - if (next && !filesById[row.id]) { - // No client (disconnected while cached rows stay visible): resolve to an - // empty file list so the row shows "No file changes" instead of a spinner - // that never completes — no request can be made. - if (!client) { - setFilesById((prev) => ({ ...prev, [row.id]: [] })) - return + const toggleCommit = useCallback((row: MobileCommitRow) => { + setExpanded((current) => (current === row.id ? null : row.id)) + }, []) + + // Why (F10): the expanded commit's files load here, not in the tap handler, so a row expanded + // during an outage refetches on reconnect instead of caching the outage's answer forever. + useEffect(() => { + if (!expanded || !client || connState !== 'connected') { + return + } + const commitId = expanded + let stale = false + setFilesById((prev) => (prev[commitId] ? prev : { ...prev, [commitId]: 'loading' })) + void client + .sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId }) + .then((response) => { + const entries = response.ok + ? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries + : [] + if (!stale) { + setFilesById((prev) => ({ ...prev, [commitId]: entries })) } - setFilesById((prev) => ({ ...prev, [row.id]: 'loading' })) - void client - .sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id }) - .then((response) => { - const entries = response.ok - ? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries - : [] - setFilesById((prev) => { - // Drop stale responses if the row is no longer loading (collapsed + re-opened). - if (prev[row.id] !== 'loading') { - return prev - } - return { ...prev, [row.id]: entries } - }) - }) - .catch(() => - setFilesById((prev) => { - if (prev[row.id] !== 'loading') { - return prev - } - return { ...prev, [row.id]: [] } - }) + }) + .catch(() => { + // Keep an already-loaded list; a first load that fails resolves to "No file changes". + if (!stale) { + setFilesById((prev) => + prev[commitId] === 'loading' ? { ...prev, [commitId]: [] } : prev ) - } - }, - [client, expanded, filesById, worktreeId] - ) + } + }) + return () => { + stale = true + } + }, [client, connState, expanded, worktreeId]) + + const connected = client !== null && connState === 'connected' const renderCommit = useCallback( ({ item }: { item: MobileCommitRow }) => { @@ -162,7 +157,12 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({ {isOpen ? ( {files === 'loading' || files === undefined ? ( - + // No request can complete while disconnected, so say so instead of spinning forever. + connected ? ( + + ) : ( + Waiting for desktop... + ) ) : files.length === 0 ? ( No file changes ) : ( @@ -183,14 +183,10 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({ ) }, - [expanded, filesById, toggleCommit] + [connected, expanded, filesById, toggleCommit] ) - const view = resolveMobileHistoryScreenView({ - connected: client !== null && connState === 'connected', - rows, - error - }) + const view = resolveMobileHistoryScreenView({ connected, rows, error }) if (view.kind === 'error' || view.kind === 'waiting') { return ( diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index a9e2b554f91..007fb317ce9 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -29,11 +29,8 @@ describe('mobile source control status helpers', () => { it('builds sections in the mobile source control order', () => { const sections = buildMobileSourceControlSections(entries) - expect(sections.map((section) => section.title)).toEqual([ - 'Changes', - 'Untracked Files', - 'Staged Changes' - ]) + expect(sections.map((section) => section.title)).toEqual(['Changes', 'Staged Changes']) + expect(sections[0]?.data.map((entry) => entry.path)).toEqual(['a.ts', 'new.ts']) }) it('computes actionable path sets', () => { diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index 65fcf61e211..2d7b0795918 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -19,7 +19,7 @@ export type MobileSourceControlSection = { unstaged: 'Changes', @@ -59,7 +59,13 @@ export function buildMobileSourceControlSections ({ area, title: AREA_TITLES[area], - data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries) + data: entries + .filter((entry) => + area === 'unstaged' + ? entry.area === 'unstaged' || entry.area === 'untracked' + : entry.area === area + ) + .sort(compareGitStatusEntries) })).filter((section) => section.data.length > 0) } diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts new file mode 100644 index 00000000000..bb76d2df75b --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function clientWith(sendRequest: RpcClient['sendRequest']): Pick { + return { sendRequest } +} + +function options(sendRequest: RpcClient['sendRequest']) { + return { + client: clientWith(sendRequest), + worktreeId: 'worktree-1', + relativePath: 'src/target.ts', + tabMode: 'diff' as const, + staged: true + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('revealMobileSourceControlSessionDiff', () => { + it('activates the requested file when its diff tab is already open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + activeTabId: 'agent-tab', + tabs: [ + { id: 'agent-tab', type: 'terminal' }, + { + id: 'other-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/other.ts' + }, + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', { + worktree: 'id:worktree-1', + tabId: 'target-diff', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + }) + + it('selects the staged diff when both versions of a file are open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + }, + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('waits for the requested diff source instead of activating the other one', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('activates a legacy edit tab when opening a diff falls back to files.open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'stale-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + }, + { + id: 'target-edit', + type: 'file', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-edit' })) + + await expect( + revealMobileSourceControlSessionDiff({ ...options(sendRequest), tabMode: 'edit' }) + ).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'target-edit' }) + ) + }) + + it('retries until the opened diff appears in the session snapshot', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce(success({ tabs: [{ id: 'agent-tab', type: 'terminal' }] })) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenCalledTimes(3) + }) + + it('delegates to the mounted session when the dock callback is available', async () => { + const sendRequest = vi.fn() + const onOpenedFileDiff = vi.fn() + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + onOpenedFileDiff + }) + ).resolves.toBe('revealed') + + expect(onOpenedFileDiff).toHaveBeenCalledWith('src/target.ts') + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels route-owned polling after the source-control screen unmounts', async () => { + let current = true + const sendRequest = vi.fn().mockImplementation(async () => { + current = false + return success({ tabs: [] }) + }) + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + isCurrent: () => current + }) + ).resolves.toBe('cancelled') + expect(sendRequest).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts new file mode 100644 index 00000000000..9391c0c2961 --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts @@ -0,0 +1,143 @@ +import type { RpcClient } from '../transport/rpc-client' +import { activateMobileSessionTab } from '../session/mobile-session-tab-activation' + +type ActivationClient = Pick + +type SessionFileTabCandidate = { + id: string + type: string + mode?: unknown + relativePath?: unknown + diffSource?: unknown +} + +type Options = { + client: ActivationClient + worktreeId: string + relativePath: string + tabMode: 'diff' | 'edit' + staged: boolean + onOpenedFileDiff?: (relativePath: string) => void + isCurrent?: () => boolean +} + +export type MobileSourceControlSessionDiffRevealResult = 'revealed' | 'cancelled' | 'timeout' + +const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const + +export async function revealMobileSourceControlSessionDiff( + options: Options +): Promise { + if (options.onOpenedFileDiff) { + options.onOpenedFileDiff(options.relativePath) + return 'revealed' + } + + for (const delayMs of TAB_POLL_DELAYS_MS) { + await waitForDelay(delayMs) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + + const tab = await findOpenedSessionFileTab(options) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (!tab) { + continue + } + + const activated = await activateSessionFileTab(options, tab.id) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (activated) { + return 'revealed' + } + } + + return 'timeout' +} + +async function findOpenedSessionFileTab(options: Options): Promise { + try { + const response = await options.client.sendRequest('session.tabs.list', { + worktree: `id:${options.worktreeId}` + }) + if (!response.ok) { + return null + } + const snapshot = readTabSnapshot(response.result) + if (!snapshot) { + return null + } + + const matches = snapshot.tabs.filter( + (tab) => + tab.type !== 'browser' && + tab.type !== 'terminal' && + matchesTabMode(tab.mode, options.tabMode) && + tab.relativePath === options.relativePath + ) + if (options.tabMode === 'edit') { + return matches[0] ?? null + } + const source = options.staged ? 'staged' : 'unstaged' + return ( + matches.find((tab) => tab.diffSource === source) ?? + matches.find((tab) => tab.diffSource == null) ?? + null + ) + } catch { + return null + } +} + +function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean { + return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null +} + +async function activateSessionFileTab(options: Options, tabId: string): Promise { + try { + const response = await activateMobileSessionTab(options.client, { + worktree: `id:${options.worktreeId}`, + tabId, + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + return response.ok && readActiveTabId(response.result) === tabId + } catch { + return false + } +} + +function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null { + if ( + !isRecord(value) || + !Array.isArray(value.tabs) || + !value.tabs.every(isSessionFileTabCandidate) + ) { + return null + } + return { tabs: value.tabs } +} + +function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' +} + +function readActiveTabId(value: unknown): string | null { + return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +async function waitForDelay(delayMs: number): Promise { + if (delayMs === 0) { + return + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} diff --git a/mobile/src/source-control/use-mobile-source-control-openers.ts b/mobile/src/source-control/use-mobile-source-control-openers.ts index bf0eff63cf6..f6c44825715 100644 --- a/mobile/src/source-control/use-mobile-source-control-openers.ts +++ b/mobile/src/source-control/use-mobile-source-control-openers.ts @@ -18,6 +18,7 @@ import { type MobileGitStatusEntry } from './mobile-git-status' import { buildMobileReviewFileRoute } from './mobile-review-route' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' import type { GitDiffTextResult, MobileBranchCompareState, @@ -115,11 +116,13 @@ export function useMobileSourceControlOpeners(params: Params) { relativePath: entry.path, staged: entry.area === 'staged' }) + let openedTabMode: 'diff' | 'edit' = 'diff' if (!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)) { response = await client.sendRequest('files.open', { worktree: `id:${worktreeId}`, relativePath: entry.path }) + openedTabMode = 'edit' } if (!response.ok) { throw new Error(response.error?.message || 'Unable to open diff') @@ -127,8 +130,22 @@ export function useMobileSourceControlOpeners(params: Params) { if (!mountedRef.current) { return } + const revealResult = await revealMobileSourceControlSessionDiff({ + client, + worktreeId, + relativePath: entry.path, + tabMode: openedTabMode, + staged: entry.area === 'staged', + onOpenedFileDiff, + isCurrent: () => mountedRef.current && openingPathRef.current === entry.path + }) + if (revealResult === 'cancelled') { + return + } + if (revealResult === 'timeout') { + throw new Error("The file opened, but its tab isn't ready yet. Try again.") + } triggerSelection() - onOpenedFileDiff?.(entry.path) // Why: when launched from the session screen, opening a file dismisses // this surface back to the session. In embedded mode there is nothing // to pop (the panel docks beside the terminal), so close the dock diff --git a/mobile/src/tasks/mobile-task-navigation.test.ts b/mobile/src/tasks/mobile-task-navigation.test.ts index 5bc519578ba..bacc24be5b8 100644 --- a/mobile/src/tasks/mobile-task-navigation.test.ts +++ b/mobile/src/tasks/mobile-task-navigation.test.ts @@ -32,8 +32,9 @@ describe('mobile task navigation', () => { it('waits for the expected host commit before navigating to Tasks', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push }, 'host/1') + navigateToMobileTasks(harness.navigation, { push, replace }, 'host/1') expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan( push.mock.invocationCallOrder[0]! @@ -46,38 +47,25 @@ describe('mobile task navigation', () => { routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() - - harness.setState({ - index: 1, - routes: [ - { name: 'index' }, - { - name: 'h', - state: { - key: '/h', - index: 0, - routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }] - } - } - ] - }) - expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan( - harness.navigation.dispatch.mock.invocationCallOrder[0]! + replace.mock.invocationCallOrder[0]! ) - expect(harness.navigation.dispatch).toHaveBeenCalledWith({ - type: 'REPLACE', - target: '/h', - source: 'host-index', - payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } } + expect(replace).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/tasks', + params: { hostId: 'host/1' } }) }) it('ignores unrelated state events and preserves the provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear') + navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1', + 'linear' + ) harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) @@ -108,30 +96,37 @@ describe('mobile task navigation', () => { it('cleanup prevents a stale navigation from replacing', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') + const replace = vi.fn() + const controller = navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace }, + 'host-1' + ) controller.cancel() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('reuses a pending host push and applies the latest provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const router = { push, replace: vi.fn() } const first = coordinateMobileTasksNavigation( null, harness.navigation, - { push }, + router, 'host-1', 'github' ) const second = coordinateMobileTasksNavigation( first, harness.navigation, - { push }, + router, 'host-1', 'linear' ) @@ -161,11 +156,12 @@ describe('mobile task navigation', () => { ) }) - it('keeps waiting through host setup and cleans up when navigation leaves', () => { + it('keeps waiting through host setup without params and cleans up when navigation leaves', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) + navigateToMobileTasks(harness.navigation, { push: vi.fn(), replace }, 'host-1') + harness.setState({ index: 0, routes: [{ name: 'h' }] }) expect(harness.unsubscribeState).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState({ @@ -184,6 +180,7 @@ describe('mobile task navigation', () => { expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('unsubscribes when mounting the host throws synchronously', () => { @@ -196,7 +193,8 @@ describe('mobile task navigation', () => { { push: () => { throw error - } + }, + replace: vi.fn() }, 'host-1' ) diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 0125cb053e6..ce360091ce2 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -39,6 +39,7 @@ describe('terminal live input affordance', () => { expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') + expect(block).toContain('liveInputText={liveInputCapture}') expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') expect(sessionRouteSource).toContain('return resetLiveInputFocus') expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') @@ -52,6 +53,8 @@ describe('terminal live input affordance', () => { it('makes the live keyboard target visible instead of status-only chrome', () => { expect(liveInputStatusSource).toContain("'Tap to show keyboard'") + expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'") + expect(liveInputStatusSource).toContain('ellipsizeMode="head"') expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') expect(commandInputStylesSource).toContain('borderWidth: 1') expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts index 88918c00681..a77d4315c29 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from 'vitest' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, - waitForTerminalLivePendingFlush, - type TerminalLivePendingFlushState + waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' describe('terminal live pending flush state', () => { it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When / Then await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) @@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => { }) describe('terminal live mirror send queue', () => { + it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => { + // Given + const state = createTerminalLivePendingFlushState() + const payloads: string[] = [] + let resolveFirstSend: (value: boolean) => void = () => {} + const sender = async (_handle: string, payload: string): Promise => { + payloads.push(payload) + if (payloads.length === 1) { + return new Promise((resolve) => { + resolveFirstSend = resolve + }) + } + return true + } + + // When + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender) + await Promise.resolve() + + // Then + expect(payloads).toEqual(['a']) + resolveFirstSend(true) + await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true]) + expect(payloads).toEqual(['a', 'bc']) + }) + it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() const order: string[] = [] - const first = queueTerminalLiveMirrorSend(state, async () => { + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { order.push('first') return false }) // When - const second = queueTerminalLiveMirrorSend(state, async () => { + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => { order.push('second') return true }) @@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => { it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } - const first = queueTerminalLiveMirrorSend(state, async () => { + const state = createTerminalLivePendingFlushState() + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { throw new Error('boom') }) // When - const second = queueTerminalLiveMirrorSend(state, async () => true) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true) // Then await expect(first).resolves.toBe(false) @@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => { it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When - await queueTerminalLiveMirrorSend(state, async () => true) + await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true) await Promise.resolve() // Then expect(state.current).toBeNull() }) + + it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => { + // Given + const state = createTerminalLivePendingFlushState() + let resolveSend: (value: boolean) => void = () => {} + const sender = async (): Promise => + new Promise((resolve) => { + resolveSend = resolve + }) + const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + + // When + cancelTerminalLivePendingFlush(state) + + // Then + await expect(Promise.all([active, pending])).resolves.toEqual([false, false]) + expect(state.current).toBeNull() + resolveSend(true) + }) }) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts index 405198964f3..fe0b4927f5a 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -1,5 +1,30 @@ +type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise + +type TerminalLivePendingRequest = { + readonly resolve: (sent: boolean) => void +} + +type TerminalLivePendingBatch = { + readonly handle: string + payload: string + readonly requests: TerminalLivePendingRequest[] + readonly sender: TerminalLiveMirrorSender +} + export type TerminalLivePendingFlushState = { current: Promise | null + activeRequests: TerminalLivePendingRequest[] + generation: number + pendingBatches: TerminalLivePendingBatch[] +} + +export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState { + return { + current: null, + activeRequests: [], + generation: 0, + pendingBatches: [] + } } export function waitForTerminalLivePendingFlush( @@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush( return state.current ?? Promise.resolve(true) } -// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped -// delta desyncs every later diff, so this chain runs each send even when the -// previous one failed. state.current should never reject; the catch keeps a -// future raw assignment from skipping a delta. +export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void { + state.generation += 1 + const requests = [ + ...state.activeRequests, + ...state.pendingBatches.flatMap((batch) => batch.requests) + ] + state.activeRequests = [] + state.pendingBatches = [] + state.current = null + requests.forEach(({ resolve }) => resolve(false)) +} + +async function drainTerminalLiveMirrorSends( + state: TerminalLivePendingFlushState, + generation: number +): Promise { + let allSent = true + while (state.generation === generation) { + const batch = state.pendingBatches.shift() + if (!batch) { + state.current = null + return allSent + } + + state.activeRequests = batch.requests + const sent = await batch.sender(batch.handle, batch.payload).catch(() => false) + if (state.generation !== generation) { + return false + } + + state.activeRequests = [] + batch.requests.forEach(({ resolve }) => resolve(sent)) + allSent &&= sent + } + return false +} + +// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke. export function queueTerminalLiveMirrorSend( state: TerminalLivePendingFlushState, - sendMirrorPayload: () => Promise + handle: string, + payload: string, + sender: TerminalLiveMirrorSender ): Promise { - const previousSend = state.current - const sendPromise = (async () => { - if (previousSend) { - await previousSend.catch(() => false) - } - return sendMirrorPayload() - })().catch(() => false) - state.current = sendPromise - void sendPromise.then(() => { - if (state.current === sendPromise) { - state.current = null - } + let resolveRequest: (sent: boolean) => void = () => {} + const request = new Promise((resolve) => { + resolveRequest = resolve }) - return sendPromise + const pendingTail = state.pendingBatches.at(-1) + if (pendingTail?.handle === handle && pendingTail.sender === sender) { + pendingTail.payload += payload + pendingTail.requests.push({ resolve: resolveRequest }) + } else { + state.pendingBatches.push({ + handle, + payload, + requests: [{ resolve: resolveRequest }], + sender + }) + } + + if (!state.current) { + const generation = state.generation + const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false) + state.current = drain + void drain.then(() => { + if (state.current === drain) { + state.current = null + } + }) + } + return request } diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index f6239312947..05617442142 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -7,6 +7,8 @@ import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' @@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush({ setLiveInputCapture }: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { const heldCommitTimerRef = useRef | null>(null) - const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState()) const heldLiveInputTextRef = useRef('') const sentLiveInputTextRef = useRef('') const pendingLiveInputHandleRef = useRef(null) @@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush({ const resetMirrorState = useCallback(() => { clearHeldCommitTimer() + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null @@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush({ }, [liveInputRef, resetMirrorState, setLiveInputCapture]) const waitForPendingLiveInputFlush = useCallback(async (): Promise => { - return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current) }, []) + const sendQueuedMirrorPayload = useCallback( + (handle: string, payload: string): Promise => + sendLiveTerminalInputRef.current(handle, payload), + [sendLiveTerminalInputRef] + ) + const runMirrorStep = useCallback( async (handle: string, fieldText: string, commitHeld: boolean): Promise => { if ( @@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush({ if (payload.length === 0) { return waitForPendingLiveInputFlush() } - return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () => - sendLiveTerminalInputRef.current(handle, payload) + return queueTerminalLiveMirrorSend( + pendingLiveInputFlushRef.current, + handle, + payload, + sendQueuedMirrorPayload ) }, [ @@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush({ clearHeldCommitTimer, liveInputTerminalHandlesRef, resetMirrorState, - sendLiveTerminalInputRef, + sendQueuedMirrorPayload, waitForPendingLiveInputFlush ] ) @@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush({ heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null - pendingLiveInputFlushRef.current = null + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) } }, []) diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index eacb68ab0b9..e7c00008e05 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -20,7 +20,7 @@ vi.mock('./connection-revival-triggers', () => ({ subscribeConnectionRevivalTriggers: () => () => {} })) -import { RpcClientProvider, useCloseHost, useHostClient } from './client-context' +import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context' type FakeClient = RpcClient & { emitState: (state: ConnectionState) => void @@ -172,7 +172,7 @@ describe('useHostClient', () => { } }) - it('stays disconnected while a reused screen resolves an uncached host', async () => { + it('shows connecting while a reused screen resolves an uncached host', async () => { const client = makeFakeClient('connected') connectMock.mockReturnValue(client) loadHostsMock.mockResolvedValueOnce([HOST]).mockReturnValueOnce(new Promise(() => {})) @@ -194,18 +194,20 @@ describe('useHostClient', () => { }) expect(stateByRenderTick.get(0)).toBe('connected') + // Why (S2): the unresolved-open window is amber, not grey — 'disconnected' + // here made every host swap flash a dead host while the Keychain read ran. selectedHostId = 'missing-host' renderTick = 1 await act(async () => { renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) }) - expect(stateByRenderTick.get(1)).toBe('disconnected') + expect(stateByRenderTick.get(1)).toBe('connecting') renderTick = 2 await act(async () => { renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) }) - expect(stateByRenderTick.get(2)).toBe('disconnected') + expect(stateByRenderTick.get(2)).toBe('connecting') } finally { restore() act(() => renderer?.unmount()) @@ -245,6 +247,73 @@ describe('useHostClient', () => { harness.unmount() }) + it('seeds connecting during the async open instead of flashing disconnected', async () => { + let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null + const hostLookup = new Promise<(typeof HOST)[]>((resolve) => { + resolveHosts = resolve + }) + connectMock.mockReturnValue(makeFakeClient('connecting')) + loadHostsMock.mockReturnValue(hostLookup) + + const states: ConnectionState[] = [] + let renderer: ReactTestRenderer | null = null + function Probe(): null { + states.push(useHostClient(HOST.id).state) + return null + } + const restore = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(states.at(-1)).toBe('connecting') + + await act(async () => { + resolveHosts?.([HOST]) + await hostLookup + }) + expect(states.at(-1)).toBe('connecting') + expect(states).not.toContain('disconnected') + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('keeps Retry amber through forceReconnect instead of grey-then-amber', async () => { + const first = makeFakeClient('connected') + const second = makeFakeClient('connecting') + connectMock.mockReturnValueOnce(first).mockReturnValueOnce(second) + loadHostsMock.mockResolvedValue([HOST]) + + const states: ConnectionState[] = [] + let forceReconnect: ((hostId: string) => Promise) | null = null + let renderer: ReactTestRenderer | null = null + function Probe(): null { + forceReconnect = useForceReconnect() + states.push(useHostClient(HOST.id).state) + return null + } + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(states.at(-1)).toBe('connected') + + await act(async () => { + await forceReconnect?.(HOST.id) + }) + expect(first.closeMock).toHaveBeenCalled() + expect(states.at(-1)).toBe('connecting') + expect(states).not.toContain('disconnected') + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + it('does not open a client after the host is closed during an in-flight lookup', async () => { let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null const hostLookup = new Promise<(typeof HOST)[]>((resolve) => { diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index eec5ba40469..8c00a11b2be 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -32,6 +32,8 @@ export type RpcClientContextValue = { forceReconnect: (hostId: string) => Promise closeHost: (hostId: string) => void getState: (hostId: string) => ConnectionState + // null = host has no client entry and no open in flight; callers pick the default. + getKnownState: (hostId: string) => ConnectionState | null getReconnectAttempt: (hostId: string) => number // Why: ms-epoch of the last 'connected' (null if never this session); UI escalates "Reconnecting…" into a re-pair prompt. getLastConnectedAt: (hostId: string) => number | null @@ -95,6 +97,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { resolve = res }) const pendingOpen = pendingOpensRef.current.register(hostId, promise) + // Why: already-mounted subscribers must go amber for the Keychain read too. + notifyHostState(hostId, 'connecting') try { // Why: prefer the primed cache so we don't serialize a second Keychain pass on cold start. @@ -209,6 +213,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { entry.client.close() storeRef.current.delete(hostId) } + // Why: Retry must read amber for the whole reopen, not grey-then-amber. + notifyHostState(hostId, 'connecting') const fresh = await openEntry(hostId) if (fresh) { fresh.refCount = savedRefCount @@ -217,10 +223,22 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { [openEntry] ) - const getState = useCallback((hostId: string): ConnectionState => { - return storeRef.current.get(hostId)?.state ?? 'disconnected' + // null = no entry and no open in flight; callers pick their own default. + const getKnownState = useCallback((hostId: string): ConnectionState | null => { + const entry = storeRef.current.get(hostId) + if (entry) { + return entry.state + } + // Why: the async open (a Keychain pass) predates the store entry; reading that + // window as 'disconnected' made every host screen flash dead on mount (S2). + return pendingOpensRef.current.getActivePromise(hostId) ? 'connecting' : null }, []) + const getState = useCallback( + (hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected', + [getKnownState] + ) + const getReconnectAttempt = useCallback((hostId: string): number => { return storeRef.current.get(hostId)?.client.getReconnectAttempt() ?? 0 }, []) @@ -284,9 +302,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { // Why: nudge live clients when the OS signals the link may be back so sessions recover without a restart (issue #5049). useEffect(() => { - return subscribeConnectionRevivalTriggers(() => { + return subscribeConnectionRevivalTriggers((reason) => { for (const entry of storeRef.current.values()) { - entry.client.notifyForeground() + entry.client.notifyForeground(reason) } }) }, []) @@ -298,6 +316,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { forceReconnect, closeHost: closeEntry, getState, + getKnownState, getReconnectAttempt, getLastConnectedAt, getActivePath, @@ -312,6 +331,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { forceReconnect, closeEntry, getState, + getKnownState, getReconnectAttempt, getLastConnectedAt, getActivePath, @@ -340,8 +360,10 @@ export function useHostClient(hostId: string | undefined): { } { const ctx = useRpcClientContext() const [, force] = useState(0) + // Why: an absent entry at mount is almost always the open racing the render, not a + // dead host — seed amber; a failed open notifies 'disconnected' moments later. const [state, setState] = useState(() => - hostId ? ctx.getState(hostId) : 'disconnected' + hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected' ) const clientRef = useRef(null) const clientHostIdRef = useRef(hostId) @@ -374,7 +396,7 @@ export function useHostClient(hostId: string | undefined): { }) const initial = ctx.acquire(hostId) clientRef.current = initial - setState(ctx.getState(hostId)) + setState(ctx.getKnownState(hostId) ?? 'connecting') if (initial) { // Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client. force((n) => n + 1) @@ -390,63 +412,14 @@ export function useHostClient(hostId: string | undefined): { // Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render. const bound = clientHostIdRef.current === hostId - const boundState = bound ? state : hostId ? ctx.getState(hostId) : 'disconnected' + const boundState = bound + ? state + : hostId + ? (ctx.getKnownState(hostId) ?? 'connecting') + : 'disconnected' return { client: bound ? clientRef.current : null, state: boundState } } -// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. -export function useAllHostClients(hostIds: string[]) { - const ctx = useRpcClientContext() - // Stable key so we don't tear down on every render of the array. - const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) - const [tick, setTick] = useState(0) - - useEffect(() => { - if (hostIds.length === 0) { - return - } - for (const id of hostIds) { - ctx.acquire(id) - } - const unsubs: Array<() => void> = [] - for (const id of hostIds) { - unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1))) - } - unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1))) - return () => { - for (const u of unsubs) { - u() - } - for (const id of hostIds) { - ctx.release(id) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key]) - - return useMemo(() => { - const out: Array<{ - hostId: string - client: RpcClient - state: ConnectionState - path: MobileConnectionPath - }> = [] - for (const id of hostIds) { - const all = ctx.getAllClients().find((entry) => entry.hostId === id) - if (all) { - out.push({ - hostId: id, - client: all.client, - state: ctx.getState(id), - path: ctx.getActivePath(id) - }) - } - } - return out - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key, tick]) -} - // Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it. export function useCloseHost(): (hostId: string) => void { const ctx = useRpcClientContext() @@ -467,5 +440,10 @@ export function usePrimeHosts(): (hosts: HostProfile[]) => void { function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { const logical = client as Partial | undefined - return typeof logical?.getActivePath === 'function' ? logical.getActivePath() : 'lan' + if (typeof logical?.getActivePath !== 'function') { + return 'lan' + } + // Why: mid-migration the active path still names the session being replaced; the + // pending one is what the user is actually waiting on (F5). + return logical.getPendingPath?.() ?? logical.getActivePath() } diff --git a/mobile/src/transport/connection-revival-triggers.ts b/mobile/src/transport/connection-revival-triggers.ts index 1391607d4bf..a7e5bb8578b 100644 --- a/mobile/src/transport/connection-revival-triggers.ts +++ b/mobile/src/transport/connection-revival-triggers.ts @@ -6,10 +6,12 @@ import { addNetworkStateListener, getNetworkStateAsync, type NetworkState } from // without an onclose. Both leave clients waiting out long backoff timers or // parked at the reconnect give-up cap (issue #5049). Surface every "the link // probably just came back" OS signal as a single nudge callback. -export function subscribeConnectionRevivalTriggers(nudge: () => void): () => void { +export function subscribeConnectionRevivalTriggers( + nudge: (reason: 'app-resume' | 'network-change') => void +): () => void { const appStateSub = AppState.addEventListener('change', (next) => { if (next === 'active') { - nudge() + nudge('app-resume') } }) let lastNetwork: Pick | null = null @@ -39,7 +41,7 @@ export function subscribeConnectionRevivalTriggers(nudge: () => void): () => voi type: state.type, cameOnline }) - nudge() + nudge('network-change') } }) return () => { diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts index 2e3e8a9f5b2..db463413a46 100644 --- a/mobile/src/transport/host-edit-navigation.test.ts +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { mobileHostEditHostRoute, - mobileHostEditRoute, + mobileHostEditRouteTarget, navigateToMobileHostEdit, type MobileHostEditNavigationState } from './host-edit-navigation' @@ -15,6 +15,7 @@ function navigationHarness(initialState: MobileHostEditNavigationState) { stateListener = listener return unsubscribeState }), + dispatch: vi.fn(), getState: () => state } return { @@ -27,51 +28,87 @@ function navigationHarness(initialState: MobileHostEditNavigationState) { } } +// Edit now waits for the nested host stack, not just the root `h` route, so every committed +// state below carries the stack the replacement targets. +function committedHostState(hostIdParam: string): MobileHostEditNavigationState { + return { + index: 1, + routes: [ + { name: 'index' }, + { + name: 'h', + state: { + key: '/h', + index: 0, + routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: hostIdParam } }] + } + } + ] + } +} + describe('mobile host edit navigation', () => { - it('waits for the expected host route to commit before replacing it with Edit', () => { + it('waits for the expected host stack to commit before replacing it with Edit', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - const replace = vi.fn() - navigateToMobileHostEdit(harness.navigation, { push, replace }, 'host/1') + navigateToMobileHostEdit(harness.navigation, { push, replace: vi.fn() }, 'host/1') expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1')) - expect(replace).not.toHaveBeenCalled() + expect(harness.navigation.dispatch).not.toHaveBeenCalled() - harness.setState({ - index: 1, - routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }] - }) + // The host route commits before its stack mounts; the old root-only predicate fired here. + harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'h' }] }) + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + + harness.setState(committedHostState('host%2F1')) expect(harness.unsubscribeState).toHaveBeenCalledOnce() - expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host/1')) + expect(harness.navigation.dispatch).toHaveBeenCalledWith({ + type: 'REPLACE', + target: '/h', + source: 'host-index', + payload: mobileHostEditRouteTarget('host/1') + }) }) it('does not replace an unrelated host route', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const replace = vi.fn() - navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace }, 'host-1') - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') + harness.setState(committedHostState('host-2')) - expect(replace).not.toHaveBeenCalled() + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + }) + + it('disposes itself when navigation leaves the host flow without cancel()', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') + + // Enter the host flow for a different host, then leave it entirely. + harness.setState(committedHostState('host-2')) + harness.setState({ index: 0, routes: [{ name: 'index' }] }) + expect(harness.unsubscribeState).toHaveBeenCalledOnce() + + // A late matching commit must not resurrect the replacement. + harness.setState(committedHostState('host-1')) + expect(harness.navigation.dispatch).not.toHaveBeenCalled() }) it('cancels a pending replacement when navigation leaves the host flow', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const replace = vi.fn() const controller = navigateToMobileHostEdit( harness.navigation, - { push: vi.fn(), replace }, + { push: vi.fn(), replace: vi.fn() }, 'host-1' ) - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) + harness.setState(committedHostState('host-2')) controller.cancel() - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) + harness.setState(committedHostState('host-1')) expect(harness.unsubscribeState).toHaveBeenCalledOnce() - expect(replace).not.toHaveBeenCalled() + expect(harness.navigation.dispatch).not.toHaveBeenCalled() }) it('unsubscribes when mounting the host throws synchronously', () => { @@ -84,8 +121,7 @@ describe('mobile host edit navigation', () => { { push: () => { throw error - }, - replace: vi.fn() + } }, 'host-1' ) diff --git a/mobile/src/transport/host-edit-navigation.ts b/mobile/src/transport/host-edit-navigation.ts index 765e36d7b5a..c1e667c5ca2 100644 --- a/mobile/src/transport/host-edit-navigation.ts +++ b/mobile/src/transport/host-edit-navigation.ts @@ -1,34 +1,27 @@ -export type MobileHostEditNavigationState = Readonly<{ - index: number - routes: readonly MobileHostEditNavigationRoute[] -}> +import { + hostStackHostRoute, + navigateToHostStackRoute, + type HostStackHostRoute, + type HostStackNavigationController, + type HostStackNavigationState, + type HostStackRootNavigation, + type HostStackRouteTarget, + type HostStackRouter +} from '../navigation/host-stack-navigation' -export type MobileHostEditNavigationRoute = Readonly<{ - name: string - params?: Readonly<{ hostId?: unknown }> -}> +export type MobileHostEditHostRoute = HostStackHostRoute +export type MobileHostEditNavigationState = HostStackNavigationState +export type MobileHostEditRootNavigation = HostStackRootNavigation +export type MobileHostEditRouter = HostStackRouter +export type MobileHostEditNavigationController = HostStackNavigationController -export type MobileHostEditRootNavigation = { - addListener: (event: 'state', listener: () => void) => () => void - getState: () => MobileHostEditNavigationState +export function mobileHostEditHostRoute(hostId: string): MobileHostEditHostRoute { + return hostStackHostRoute(hostId) } -export type MobileHostEditRouter = { - push: (href: `/h/${string}`) => void - replace: (href: ReturnType) => void -} - -export type MobileHostEditNavigationController = Readonly<{ - cancel: () => void -}> - -export function mobileHostEditHostRoute(hostId: string): `/h/${string}` { - return `/h/${encodeURIComponent(hostId)}` -} - -export function mobileHostEditRoute(hostId: string) { +export function mobileHostEditRouteTarget(hostId: string): HostStackRouteTarget { return { - pathname: '/h/[hostId]/edit' as const, + name: '[hostId]/edit', params: { hostId } } } @@ -38,44 +31,5 @@ export function navigateToMobileHostEdit( router: MobileHostEditRouter, hostId: string ): MobileHostEditNavigationController { - let active = true - let hostRouteSeen = false - let unsubscribeState = () => {} - const dispose = () => { - if (!active) { - return - } - active = false - unsubscribeState() - } - - // Why: cold Expo deep links resolve to index; target Edit after the host route commits. - const onState = () => { - if (!active) { - return - } - const state = navigation.getState() - const currentRoute = state.routes[state.index] - if (currentRoute?.name !== 'h') { - if (hostRouteSeen) { - dispose() - } - return - } - hostRouteSeen = true - if (currentRoute.params?.hostId !== hostId) { - return - } - dispose() - router.replace(mobileHostEditRoute(hostId)) - } - - try { - unsubscribeState = navigation.addListener('state', onState) - router.push(mobileHostEditHostRoute(hostId)) - } catch (error) { - dispose() - throw error - } - return { cancel: dispose } + return navigateToHostStackRoute(navigation, router, hostId, mobileHostEditRouteTarget(hostId)) } diff --git a/mobile/src/transport/host-logical-client.ts b/mobile/src/transport/host-logical-client.ts index 7d90b32c8f2..7682fc9591b 100644 --- a/mobile/src/transport/host-logical-client.ts +++ b/mobile/src/transport/host-logical-client.ts @@ -28,9 +28,11 @@ export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSin closeLogical() } const notifyLogicalForeground = logical.notifyForeground - logical.notifyForeground = () => { - endpointLifecycle.setForeground(true) - notifyLogicalForeground() + logical.notifyForeground = (reason = 'focus') => { + // Why: a nudge while already foreground must not re-enter setForeground — + // that path suspended healthy relays; the supervisor probes or replaces instead. + endpointLifecycle.nudge(reason) + notifyLogicalForeground(reason) } return logical } diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts index ce5974b1f84..483cfcca50f 100644 --- a/mobile/src/transport/host-status-gates.test.ts +++ b/mobile/src/transport/host-status-gates.test.ts @@ -120,7 +120,7 @@ describe('useHostStatusGates', () => { } }) - it('fails closed while the same client reconnects', async () => { + it('keeps the proven gates while the same client reconnects, pending until it re-answers', async () => { let resolveReconnect: ((response: unknown) => void) | null = null const pendingReconnect = new Promise((resolve) => { resolveReconnect = resolve @@ -152,12 +152,20 @@ describe('useHostStatusGates', () => { await act(async () => { renderer?.update(createElement(Probe, { connState: 'disconnected' })) }) + // Why (F10): the drop invalidates nothing the host already proved — capabilities survive it. + expect(gates).toMatchObject({ + hostCapabilities: ['browser.screencast.v1'], + floatingWorkspaceEnabled: true, + statusPending: false + }) + await act(async () => { renderer?.update(createElement(Probe, { connState: 'connected' })) }) expect(gates).toMatchObject({ - hostCapabilities: [], - floatingWorkspaceEnabled: false + hostCapabilities: ['browser.screencast.v1'], + floatingWorkspaceEnabled: true, + statusPending: true }) await act(async () => { @@ -169,11 +177,48 @@ describe('useHostStatusGates', () => { }) expect(gates).toMatchObject({ hostCapabilities: ['terminal.quick-commands.v1'], - floatingWorkspaceEnabled: true + floatingWorkspaceEnabled: true, + statusPending: false }) } finally { restore() renderer?.unmount() } }) + + it('fails closed when the same host reconnects on a replaced client', async () => { + const firstClient = { + sendRequest: vi.fn().mockResolvedValue({ + ok: true, + result: { capabilities: ['browser.screencast.v1'], floatingWorkspaceEnabled: true } + }) + } as unknown as RpcClient + const secondClient = { + sendRequest: vi.fn().mockReturnValue(new Promise(() => {})) + } as unknown as RpcClient + let gates: HostStatusGates | null = null + let renderer: ReactTestRenderer | null = null + + function Probe({ client }: { client: RpcClient }): null { + gates = useHostStatusGates({ hostId: 'host-1', client, connState: 'connected' }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe, { client: firstClient })) + await Promise.resolve() + }) + expect(gates?.hostCapabilities).toEqual(['browser.screencast.v1']) + + await act(async () => { + renderer?.update(createElement(Probe, { client: secondClient })) + }) + expect(gates).toMatchObject({ hostCapabilities: [], statusPending: true }) + } finally { + restore() + renderer?.unmount() + } + }) }) diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts index 8a26f5cb6a7..f9837a8ac17 100644 --- a/mobile/src/transport/host-status-gates.ts +++ b/mobile/src/transport/host-status-gates.ts @@ -11,7 +11,8 @@ export type HostStatusGates = { statusPending: boolean } -type LoadedHostStatusGates = HostStatusGates & { +// statusPending is not stored: pending-ness belongs to the live connection, not to the answer. +type LoadedHostStatusGates = Omit & { hostId: string | undefined client: RpcClient } @@ -27,15 +28,21 @@ export function useHostStatusGates(args: { }): HostStatusGates { const { hostId, client, connState } = args const [loaded, setLoaded] = useState(null) + // Why (F10): a drop must not erase proven capabilities, but it does invalidate them — this keeps + // statusPending true across the reconnect refetch, so gates stay "unknown" while the data survives. + const [unverified, setUnverified] = useState(false) useEffect(() => { if (connState !== 'connected' || !client) { - // Why: reconnecting the same host/client must revalidate gates instead of reviving its prior status response. - setLoaded(null) + setUnverified(true) return } let cancelled = false const requestClient = client + const settle = (gates: Omit) => { + setLoaded({ hostId, client: requestClient, ...gates }) + setUnverified(false) + } void (async () => { try { const response = await requestClient.sendRequest('status.get') @@ -43,13 +50,10 @@ export function useHostStatusGates(args: { return } if (!response.ok) { - setLoaded({ - hostId, - client: requestClient, + settle({ hostCapabilities: [], floatingWorkspaceEnabled: false, - compatVerdict: { kind: 'ok' }, - statusPending: false + compatVerdict: { kind: 'ok' } }) return } @@ -60,13 +64,10 @@ export function useHostStatusGates(args: { desktopProtocolVersion: status.protocolVersion, desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion }) - setLoaded({ - hostId, - client: requestClient, + settle({ hostCapabilities: status.capabilities ?? [], floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, - compatVerdict: verdict, - statusPending: false + compatVerdict: verdict }) if (verdict.kind === 'blocked') { // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. @@ -80,13 +81,10 @@ export function useHostStatusGates(args: { } catch { // Why: a transient status failure must not trap navigation; conservative feature gates remain disabled. if (!cancelled) { - setLoaded({ - hostId, - client: requestClient, + settle({ hostCapabilities: [], floatingWorkspaceEnabled: false, - compatVerdict: { kind: 'ok' }, - statusPending: false + compatVerdict: { kind: 'ok' } }) } } @@ -97,13 +95,8 @@ export function useHostStatusGates(args: { }, [client, connState, hostId]) // Why: effects run after render, so key loaded gates by host and client to fail closed during route reuse. - if ( - connState !== 'connected' || - !client || - !loaded || - loaded.hostId !== hostId || - loaded.client !== client - ) { + const proven = loaded && loaded.hostId === hostId && loaded.client === client ? loaded : null + if (!proven) { return { hostCapabilities: EMPTY_HOST_CAPABILITIES, floatingWorkspaceEnabled: false, @@ -112,9 +105,11 @@ export function useHostStatusGates(args: { } } return { - hostCapabilities: loaded.hostCapabilities, - floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled, - compatVerdict: loaded.compatVerdict, - statusPending: false + hostCapabilities: proven.hostCapabilities, + floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled, + compatVerdict: proven.compatVerdict, + // Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no + // longer blanks the capabilities this same host already proved. + statusPending: connState === 'connected' && unverified } } diff --git a/mobile/src/transport/migration-dial-state-forwarder.ts b/mobile/src/transport/migration-dial-state-forwarder.ts new file mode 100644 index 00000000000..de97c9a5664 --- /dev/null +++ b/mobile/src/transport/migration-dial-state-forwarder.ts @@ -0,0 +1,53 @@ +import type { RpcClient } from './rpc-client' +import type { ConnectionState } from './types' + +// Why: a replacement session publishes these to nobody until migrateTo binds it, so a +// suspended client shows grey for the whole dial (S2). 'connected' stays excluded — the +// migration tail owns it, and forwarding it early would advertise a session that still +// rejects requests. +const DIAL_PHASES: ConnectionState[] = ['connecting', 'handshaking', 'reconnecting'] + +export type MigrationDialStateForwarder = { + forwarded: () => boolean + stop: () => void +} + +// Why: start only from an idle logical client and abandon the moment anything else +// publishes 'connected' — make-before-break replacement dials (lease rotation, the +// supervisor's suspectActive recovery) must never blink the dot on their way through. +// 'reconnecting' is deliberately not a start state: the previous session's retry loop +// is still bound and cycling, so two publishers would fight over a dot that is already +// amber. That case is served by the pending path label instead, not by forwarding. +function canForward(started: boolean, state: ConnectionState, suspended: boolean): boolean { + if (suspended) { + return true + } + return started ? state !== 'connected' : state === 'disconnected' +} + +export function forwardMigrationDialState(args: { + session: RpcClient + snapshot: () => { state: ConnectionState; suspended: boolean } + publish: (state: ConnectionState) => void +}): MigrationDialStateForwarder { + let started = false + let stopped = false + const unsubscribe = args.session.onStateChange((next) => { + if (stopped || !DIAL_PHASES.includes(next)) { + return + } + const { state, suspended } = args.snapshot() + if (!canForward(started, state, suspended)) { + return + } + started = true + args.publish(next) + }) + return { + forwarded: () => started, + stop: () => { + stopped = true + unsubscribe() + } + } +} diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index d41898152c3..76ba92547e0 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -1,5 +1,5 @@ import * as ExpoCrypto from 'expo-crypto' -import type { ConnectionLogSink, HostProfile } from './types' +import type { ConnectionLogSink, ForegroundNudgeReason, HostProfile } from './types' import { connect } from './rpc-client' import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session' @@ -15,6 +15,7 @@ import type { StableLogicalRpcClient } from './stable-logical-rpc-client' type EndpointLifecycle = { setForeground(foreground: boolean): void + nudge(reason: ForegroundNudgeReason): void stop(): void } @@ -63,6 +64,14 @@ export function startMobileEndpointLifecycle( foreground = next owner.setForeground(next) }, + nudge(reason) { + // Why: a focus nudge can precede the AppState listener; keep the closure in + // sync or a later supervisor swap would start with a stale background flag. + if (reason !== 'network-change') { + foreground = true + } + owner.nudge(reason) + }, stop() { stopped = true owner.stop() diff --git a/mobile/src/transport/mobile-endpoint-nudge-router.test.ts b/mobile/src/transport/mobile-endpoint-nudge-router.test.ts new file mode 100644 index 00000000000..a8014a90228 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-nudge-router.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' + +function routerFixture() { + let foreground = false + const logical = { + getActivePath: vi.fn(() => 'relay'), + getState: vi.fn(() => 'connected'), + getGeneration: vi.fn(() => 1), + sendRequest: vi.fn(async () => ({})) + } as unknown as StableLogicalRpcClient + const handleActiveNudge = vi.fn(() => 'probe' as const) + const setForeground = vi.fn((next: boolean) => { + foreground = next + }) + const scheduleDirectProbe = vi.fn() + const router = new MobileEndpointNudgeRouter({ + logical, + controller: { handleActiveNudge } as unknown as RelayReconnectController, + now: () => 20_000, + isStopped: () => false, + isForeground: () => foreground, + setForeground, + replaceRelay: vi.fn(), + recoverAfterDeadProbe: vi.fn(), + scheduleDirectProbe + }) + return { handleActiveNudge, logical, router, scheduleDirectProbe, setForeground } +} + +describe('MobileEndpointNudgeRouter', () => { + it('processes a focus nudge after restoring lagging foreground state', () => { + const fixture = routerFixture() + + fixture.router.nudge('focus') + + expect(fixture.setForeground).toHaveBeenCalledWith(true) + expect(fixture.handleActiveNudge).toHaveBeenCalledWith(fixture.logical, 'focus') + expect(fixture.logical.sendRequest).toHaveBeenCalledWith('status.get', null, { + timeoutMs: 4000 + }) + expect(fixture.scheduleDirectProbe).toHaveBeenCalledOnce() + }) + + it('ignores a network nudge while backgrounded', () => { + const fixture = routerFixture() + + fixture.router.nudge('network-change') + + expect(fixture.setForeground).not.toHaveBeenCalled() + expect(fixture.handleActiveNudge).not.toHaveBeenCalled() + expect(fixture.scheduleDirectProbe).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-endpoint-nudge-router.ts b/mobile/src/transport/mobile-endpoint-nudge-router.ts new file mode 100644 index 00000000000..d59c99b7c83 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-nudge-router.ts @@ -0,0 +1,53 @@ +import { MobileRelayFocusProbe } from './mobile-relay-focus-probe' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ForegroundNudgeReason } from './types' + +// Routes attention/network nudges: focus and app-resume probe a healthy relay, +// a network change replaces it make-before-break, everything else re-enters recovery. +export class MobileEndpointNudgeRouter { + private readonly focusProbe: MobileRelayFocusProbe + + constructor( + private readonly args: { + logical: StableLogicalRpcClient + controller: RelayReconnectController + now: () => number + isStopped: () => boolean + isForeground: () => boolean + setForeground: (foreground: boolean) => void + replaceRelay: () => void + recoverAfterDeadProbe: (detail: string) => void + scheduleDirectProbe: () => void + } + ) { + this.focusProbe = new MobileRelayFocusProbe({ + logical: args.logical, + now: args.now, + canProbe: () => !args.isStopped() && args.isForeground(), + onDead: args.recoverAfterDeadProbe + }) + } + + nudge(reason: ForegroundNudgeReason): void { + const { args } = this + if (args.isStopped()) { + return + } + if (!args.isForeground()) { + // Why: a background network flap must not re-open a billed relay splice; + // focus/app-resume imply the app is visible even if AppState lags. + if (reason === 'network-change') { + return + } + args.setForeground(true) + } + const verdict = args.controller.handleActiveNudge(args.logical, reason) + if (verdict === 'probe') { + void this.focusProbe.probe() + } else if (verdict === 'replace') { + args.replaceRelay() + } + args.scheduleDirectProbe() + } +} diff --git a/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts new file mode 100644 index 00000000000..b839732cf77 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayOuterError } from './mobile-relay-e2ee-link' +import { + bundle, + dependencies, + FakeLogicalClient, + FakeRelaySession, + FakeSession, + host +} from './mobile-endpoint-supervisor-test-fakes' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +// Focus/network nudge routing, make-before-break replacement, and the +// happy-eyeballs race — split from the main supervisor suite (max-lines). +describe('mobile endpoint supervisor nudges', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-13T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('replaces a relay make-before-break on a network nudge without going grey', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const deps = dependencies({ + openDirect: vi.fn(() => new FakeSession('disconnected')) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + expect(deps.openRelay).toHaveBeenCalledOnce() + expect(logical.getActivePath()).toBe('relay') + + // The OS reports a network handoff, but the relay never published onclose. + // The replacement authenticates, migrateTo swaps sessions — never disconnected. + supervisor.nudge('network-change') + await vi.advanceTimersByTimeAsync(0) + expect(deps.openRelay).toHaveBeenCalledTimes(2) + expect(logical.suspendActiveSession).not.toHaveBeenCalled() + expect(logical.getState()).toBe('connected') + supervisor.stop() + }) + + it('suspends only after a failed replacement dial, then backs off further nudges', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce(new FakeRelaySession('connected')) + .mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))) + const deps = dependencies({ + openRelay, + // Keep direct unavailable so relay recovery stays the only path under test. + openDirect: vi.fn(() => new FakeSession('disconnected')), + // Deterministic full jitter: fraction 0.5 → half the backoff window. + randomBytes: () => new Uint8Array([128, 0]) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + expect(openRelay).toHaveBeenCalledOnce() + + // PEER_DROPPED on the replacement: the suspect session comes down so the + // armed retry can run, and the failure books the shared cooldown. + supervisor.nudge('network-change') + await vi.advanceTimersByTimeAsync(0) + expect(logical.suspendActiveSession).toHaveBeenCalledOnce() + expect(openRelay).toHaveBeenCalledTimes(2) + + // More flap nudges share the existing cooldown rather than opening sockets. + for (let i = 0; i < 5; i++) { + supervisor.nudge('network-change') + await vi.advanceTimersByTimeAsync(0) + } + expect(openRelay).toHaveBeenCalledTimes(2) + + // Exactly one retry fires at the 250 ms deterministic backoff boundary. + await vi.advanceTimersByTimeAsync(249) + expect(openRelay).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(openRelay).toHaveBeenCalledTimes(3) + supervisor.stop() + }) + + it('probes a healthy relay on a focus nudge and keeps it untouched', async () => { + const logical = new FakeLogicalClient('connected', 'relay') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + + supervisor.nudge('focus') + await vi.advanceTimersByTimeAsync(0) + expect(logical.sendRequest).toHaveBeenCalledWith('status.get', null, { timeoutMs: 4000 }) + expect(logical.suspendActiveSession).not.toHaveBeenCalled() + expect(deps.openRelay).not.toHaveBeenCalled() + + // Repeated focus events inside the probe window coalesce into one probe. + supervisor.nudge('focus') + await vi.advanceTimersByTimeAsync(0) + expect(logical.sendRequest).toHaveBeenCalledOnce() + supervisor.stop() + }) + + it('suspends and re-dials when the focus probe fails', async () => { + const logical = new FakeLogicalClient('connected', 'relay') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + + logical.sendRequest.mockRejectedValueOnce(new Error('relay RPC timed out: status.get')) + supervisor.nudge('focus') + await vi.advanceTimersByTimeAsync(0) + expect(logical.suspendActiveSession).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(deps.openRelay).toHaveBeenCalledOnce()) + expect(logical.getState()).toBe('connected') + supervisor.stop() + }) + + it('queues a network replacement that lands while another dial owns the mutex', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + let resolveWrite: (() => void) | null = null + const deps = dependencies({ + openDirect: vi.fn(() => new FakeSession('disconnected')), + writeBundle: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockImplementation( + () => + new Promise((resolve) => { + resolveWrite = resolve + }) + ) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + expect(deps.openRelay).toHaveBeenCalledOnce() + + // First handoff nudge dials a replacement whose bookkeeping write is slow, + // holding the recovery mutex; the second nudge arriving then must neither + // suspend the session nor be dropped. + supervisor.nudge('network-change') + await vi.advanceTimersByTimeAsync(0) + expect(deps.openRelay).toHaveBeenCalledTimes(2) + supervisor.nudge('network-change') + expect(logical.suspendActiveSession).not.toHaveBeenCalled() + + resolveWrite?.() + await vi.advanceTimersByTimeAsync(0) + // The in-flight replacement satisfies the queued intent — no third socket. + expect(deps.openRelay).toHaveBeenCalledTimes(2) + expect(logical.getState()).toBe('connected') + supervisor.stop() + }) + + it('keeps a healthy relay bound when a nudge finds no dialable credential', async () => { + const logical = new FakeLogicalClient('connected', 'relay') + const expired = { + ...bundle, + current: { ...bundle.current, expiresAt: 1 } + } + const deps = dependencies({ + readBundle: vi.fn(async () => expired), + randomBytes: () => new Uint8Array([128, 0]) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + + supervisor.nudge('network-change') + await vi.advanceTimersByTimeAsync(0) + // Why: no dial happened, so nothing has disproven the live session. + expect(logical.suspendActiveSession).not.toHaveBeenCalled() + expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.getState()).toBe('connected') + supervisor.stop() + }) + + it('withdraws the racing relay dial when direct authenticated while it was in flight', async () => { + // Direct won between the grace timer firing and the relay session + // authenticating; the migration must withdraw instead of closing the winner. + const logical = new FakeLogicalClient('connected', 'lan') + const relaySession = new FakeRelaySession('connected') + const setActiveSession = vi.fn() + const establisher = new MobileRelaySessionEstablisher({ + logical, + controller: { setActiveSession } as unknown as RelayReconnectController, + openRelay: vi.fn(() => relaySession), + randomBytes: (length) => new Uint8Array(length), + writeBundle: vi.fn(async () => {}), + isActive: () => true, + isForeground: () => true, + relay: () => host.relay, + resolveRelay: vi.fn(async ({ relay: endpoint }) => endpoint), + persistResolvedRelay: vi.fn(async () => {}), + bundle: () => bundle, + adoptBundle: vi.fn(), + recordMigration: vi.fn(), + scheduleLease: vi.fn(), + scheduleDirectProbe: vi.fn(), + onBookkeepingError: vi.fn(), + onDialFailure: vi.fn() + }) + + const outcome = await establisher.dialEligible([bundle.current]) + expect(outcome).toEqual({ outcome: 'aborted' }) + expect(setActiveSession).not.toHaveBeenCalled() + expect(relaySession.close).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('lan') + }) +}) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-support.ts b/mobile/src/transport/mobile-endpoint-supervisor-support.ts index 28318ffd4a8..002ac15fe8e 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-support.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-support.ts @@ -1,8 +1,62 @@ import { RelayOuterError } from './mobile-relay-e2ee-link' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { HostProfile } from './types' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' +// Why: a suspect session that survived a failed replacement dial must come down, +// else the armed unforced retry dead-ends on needsRecovery seeing stale 'connected'. +export function suspendRelayIfStillConnected( + controller: RelayReconnectController, + logical: StableLogicalRpcClient +): void { + if (logical.getState() === 'connected') { + controller.suspendActiveRelay(logical) + } +} + +type RelayDialResult = { ok: true } | { ok: false; error: Error } + +// Why: a locally-aborted dial (background/stop/missing state) proves nothing about the +// cell assignment, so the director fallback must not burn a resolution round on it. +export class RelayDialAbortedError extends Error { + constructor() { + super('relay dial aborted before opening a session') + } +} + +// One credential attempt: dial, and on a director-class failure re-resolve the +// cell assignment, persist it durably, then dial once more. +export async function dialRelayThroughDirectorFallback(args: { + resumeToken: string + relay: () => HostProfile['relay'] + dial: () => Promise + resolveRelay: (input: { + relay: NonNullable + resumeToken: string + }) => Promise + persistResolvedRelay: (resolved: MobileRelayEndpoint) => Promise +}): Promise { + const first = await args.dial() + const relay = args.relay() + if ( + first.ok || + first.error instanceof RelayDialAbortedError || + !isDirectorResolutionFailure(first.error) || + !relay + ) { + return first + } + try { + const resolved = await args.resolveRelay({ relay, resumeToken: args.resumeToken }) + await args.persistResolvedRelay(resolved) + return await args.dial() + } catch (error) { + return { ok: false, error: toError(error) } + } +} + export function isDirectorResolutionFailure(error: Error): boolean { return ( !(error instanceof MobileE2EEAuthenticationError) && diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index d483c6536e8..2ef13c0b590 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -72,17 +72,31 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl this.path = path } - migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { - if (session.getState() !== 'connected') { - session.close() - throw new Error(`replacement session ${session.getState()}`) + migrateTo = vi.fn( + async ( + session: RpcClient, + path: MobileConnectionPath, + _timeoutMs?: number, + shouldAbort?: () => boolean + ) => { + if (session.getState() !== 'connected') { + session.close() + throw new Error(`replacement session ${session.getState()}`) + } + // Mirrors the real client: a racing caller withdraws after auth, before the swap. + if (shouldAbort?.()) { + session.close() + throw new Error('migration superseded') + } + this.path = path + this.generation += 1 + this.publishState('connected') } - this.path = path - this.generation += 1 - this.publishState('connected') - }) + ) suspendActiveSession = vi.fn(() => this.publishState('disconnected')) getActivePath = () => this.path + // This fake migrates instantly, so no dial is ever in flight to name. + getPendingPath = () => null getGeneration = () => this.generation } diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index d1b8b6918cd..bddc09795be 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -35,7 +35,12 @@ describe('mobile endpoint supervisor', () => { await supervisor.start() - expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay') + expect(logical.migrateTo).toHaveBeenCalledWith( + expect.any(FakeRelaySession), + 'relay', + undefined, + expect.any(Function) + ) expect(logical.getActivePath()).toBe('relay') expect(deps.writeBundle).toHaveBeenCalledWith( expect.objectContaining({ current: expect.objectContaining({ version: 2 }) }) @@ -60,7 +65,12 @@ describe('mobile endpoint supervisor', () => { logical.publishState('reconnecting') await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay')) - expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay') + expect(logical.migrateTo).toHaveBeenCalledWith( + expect.any(FakeRelaySession), + 'relay', + undefined, + expect.any(Function) + ) supervisor.stop() }) @@ -71,7 +81,12 @@ describe('mobile endpoint supervisor', () => { await supervisor.start() - expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay') + expect(logical.migrateTo).toHaveBeenCalledWith( + expect.any(FakeRelaySession), + 'relay', + undefined, + expect.any(Function) + ) expect(logical.getActivePath()).toBe('relay') supervisor.stop() }) @@ -156,46 +171,6 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) - it('replaces a half-open relay on a network nudge, then backs off failed resumes', async () => { - const logical = new FakeLogicalClient('disconnected', 'lan') - const openRelay = vi - .fn() - .mockReturnValueOnce(new FakeRelaySession('connected')) - .mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))) - const deps = dependencies({ - openRelay, - // Keep direct unavailable so relay recovery stays the only path under test. - openDirect: vi.fn(() => new FakeSession('disconnected')), - // Deterministic full jitter: fraction 0.5 → half the backoff window. - randomBytes: () => new Uint8Array([128, 0]) - }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) - - await supervisor.start() - expect(openRelay).toHaveBeenCalledOnce() - - // The OS reports a network handoff, but the dead relay never published onclose. - supervisor.setForeground(true) - await vi.advanceTimersByTimeAsync(0) - expect(logical.suspendActiveSession).toHaveBeenCalledOnce() - expect(openRelay).toHaveBeenCalledTimes(2) - - // The relay cell rejects the replacement with PEER_DROPPED; more flap nudges - // must share the existing cooldown rather than opening more sockets. - for (let i = 0; i < 5; i++) { - supervisor.setForeground(true) - await vi.advanceTimersByTimeAsync(0) - } - expect(openRelay).toHaveBeenCalledTimes(2) - - // Exactly one retry fires at the 250 ms deterministic backoff boundary. - await vi.advanceTimersByTimeAsync(249) - expect(openRelay).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(1) - expect(openRelay).toHaveBeenCalledTimes(3) - supervisor.stop() - }) - it('backs off a close from the active relay before opening its replacement', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi @@ -714,7 +689,10 @@ describe('mobile endpoint supervisor', () => { await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) - supervisor.setForeground(true) + // A network nudge inside the cooldown must not dial early and must not tear + // down the healthy session; the queued intent runs at the 250 ms boundary. + supervisor.nudge('network-change') + expect(logical.suspendActiveSession).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(249) expect(openRelay).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(1) @@ -768,6 +746,91 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) + it('races a relay dial when the direct dial stalls unauthenticated', async () => { + const logical = new FakeLogicalClient('connecting', 'lan') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(2_499) + expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.getState()).toBe('connecting') + + // The direct dial never authenticates; the relay wins the race through migrateTo. + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay')) + expect(logical.migrateTo).toHaveBeenCalledWith( + expect.any(FakeRelaySession), + 'relay', + undefined, + expect.any(Function) + ) + supervisor.stop() + }) + + it('cancels the grace race when the direct dial authenticates first', async () => { + const logical = new FakeLogicalClient('connecting', 'lan') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + logical.publishState('connected') + expect(vi.getTimerCount()).toBe(0) + + await vi.advanceTimersByTimeAsync(5_000) + expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.getActivePath()).toBe('lan') + supervisor.stop() + }) + + it('never races a relay dial against a desktop with no relay endpoint', async () => { + const logical = new FakeLogicalClient('connecting', 'lan') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, { ...host, relay: undefined }, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(5_000) + + expect(deps.openRelay).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + supervisor.stop() + }) + + it('drops the pending grace race when the phone backgrounds', async () => { + const logical = new FakeLogicalClient('connecting', 'lan') + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + supervisor.setForeground(false) + await vi.advanceTimersByTimeAsync(5_000) + + expect(deps.openRelay).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + supervisor.stop() + }) + + it('books the shared cooldown when the grace race loses its dial', async () => { + const logical = new FakeLogicalClient('connecting', 'lan') + const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))) + const deps = dependencies({ openRelay, randomBytes: () => new Uint8Array([128, 0]) }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(2_500) + expect(openRelay).toHaveBeenCalledOnce() + + // The armed retry runs unforced, so it yields to the still-progressing direct + // dial: the race gets one attempt, never a socket-per-cooldown loop. + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledOnce() + + // Direct finally gives up: ordinary recovery still owns the failure. + logical.publishState('reconnecting') + await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2)) + supervisor.stop() + }) + it('releases a background relay session and reconnects it on foreground', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies() diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index be295fdcdda..13cb1293e8e 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -4,21 +4,21 @@ import { RelayReconnectController } from './mobile-relay-reconnect-controller' import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer' import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' import { - encodeBase64Url, - isDirectorResolutionFailure, persistRelayHost, - toError + suspendRelayIfStillConnected } from './mobile-endpoint-supervisor-support' import { selectDialableRelayCredentials } from './mobile-relay-credential-selection' import { createRelayRecoveryLog, type RelayRecoveryLog } from './mobile-relay-recovery-log' import { - applyResumeConfirmation, mobileRelayCredentialNeedsRotation, rotateMobileRelayCredential } from './mobile-relay-credential-rotation' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router' +import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer' +import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' -import type { HostProfile } from './types' +import type { ForegroundNudgeReason, HostProfile } from './types' export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract' @@ -31,6 +31,8 @@ export class MobileEndpointSupervisor { private stopped = false private foreground = true private operationInFlight = false + private pendingReplace = false + private readonly nudgeRouter: MobileEndpointNudgeRouter private credentialRotationInFlight = false private relayRotationPending = false private unsubscribeState: (() => void) | null = null @@ -39,6 +41,8 @@ export class MobileEndpointSupervisor { private readonly leaseRotation: RelayLeaseRotationTimer private readonly logRelay: RelayRecoveryLog private readonly directProbe: DirectReturnProbe + private readonly directGrace: MobileRelayDirectGraceTimer + private readonly sessionEstablisher: MobileRelaySessionEstablisher constructor( private readonly logical: StableLogicalRpcClient, @@ -53,10 +57,61 @@ export class MobileEndpointSupervisor { }) this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog) this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this)) + this.nudgeRouter = new MobileEndpointNudgeRouter({ + logical, + controller: this.relayReconnect, + now: dependencies.now, + isStopped: () => this.stopped, + isForeground: () => this.foreground, + setForeground: (foreground) => this.setForeground(foreground), + replaceRelay: () => void this.recoverRelay(true, true), + recoverAfterDeadProbe: (detail) => { + this.logRelay('relay probe failed; recovering', detail) + suspendRelayIfStillConnected(this.relayReconnect, this.logical) + void this.recoverRelay() + }, + scheduleDirectProbe: () => this.directProbe.schedule(0) + }) this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => { this.relayRotationPending = true void this.recoverRelay(true) }) + // Why: the race owns recovery exactly like a network-change replacement — its + // failure must book the shared cooldown. recoverRelay's own guards already + // cover stopped/background/no-relay, so the timer needs no scope check. + this.directGrace = new MobileRelayDirectGraceTimer(dependencies, logical, () => { + void this.recoverRelay(true, true) + }) + this.sessionEstablisher = new MobileRelaySessionEstablisher({ + logical, + controller: this.relayReconnect, + openRelay: dependencies.openRelay, + randomBytes: dependencies.randomBytes, + writeBundle: dependencies.writeBundle, + isActive: () => !this.stopped && this.foreground, + isForeground: () => this.foreground, + relay: () => this.host.relay, + resolveRelay: dependencies.resolveRelay, + persistResolvedRelay: async (resolved) => { + this.host = await persistRelayHost(this.host, resolved, dependencies.saveHost) + }, + bundle: () => this.bundle, + adoptBundle: (bundle) => { + this.bundle = bundle + }, + recordMigration: () => { + this.relayRotationPending = false + this.hysteresis.recordMigration(dependencies.now()) + this.logRelay('runtime channel migrated to relay') + }, + scheduleLease: (expiry) => + this.leaseRotation.scheduleFromLease(this.stopped || !this.foreground ? null : expiry), + scheduleDirectProbe: () => this.directProbe.schedule(), + onBookkeepingError: (error) => + this.logRelay('relay bookkeeping failed after migration', error.message.slice(0, 80)), + onDialFailure: (error) => + this.logRelay('relay dial failed', `${error.name}: ${String(error.message).slice(0, 80)}`) + }) this.directProbe = new DirectReturnProbe(dependencies, { hysteresis: this.hysteresis, host: () => this.host, @@ -74,7 +129,11 @@ export class MobileEndpointSupervisor { }, afterProbe: () => { this.operationInFlight = false - if (this.relayRotationPending || this.logical.getState() !== 'connected') { + if ( + this.pendingReplace || + this.relayRotationPending || + this.logical.getState() !== 'connected' + ) { void this.recoverRelay(this.relayRotationPending) } } @@ -93,6 +152,7 @@ export class MobileEndpointSupervisor { } this.unsubscribeState = this.logical.onStateChange((state) => { if (state === 'connected') { + this.directGrace.clear() if (this.logical.getActivePath() !== 'relay') { void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) } @@ -109,6 +169,7 @@ export class MobileEndpointSupervisor { await this.recoverRelay() } else { this.directProbe.schedule() + this.directGrace.arm() } } @@ -118,15 +179,21 @@ export class MobileEndpointSupervisor { if (foreground) { this.relayReconnect.handleForeground(this.logical, wasForeground) this.directProbe.schedule(0) + this.directGrace.arm() } else { // Why: background phones must not hold billed relay data splices. this.relayReconnect.suspendActiveRelay(this.logical) this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() + this.directGrace.clear() } } + nudge(reason: ForegroundNudgeReason): void { + this.nudgeRouter.nudge(reason) + } + stop(): void { this.stopped = true this.unsubscribeState?.() @@ -134,22 +201,42 @@ export class MobileEndpointSupervisor { this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() + this.directGrace.clear() } - private async recoverRelay(forceReplacement = false): Promise { - // Why: connecting/handshaking is live direct progress; a relay dial would race it. - if ( - this.stopped || - !this.foreground || - this.operationInFlight || - !this.host.relay || - (!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState())) - ) { + // forceReplacement: dial past the "direct still looks live" guard — a lease + // rotation, a network-change replacement, or the happy-eyeballs grace race. + // ownsRecovery: this dial is the connection's only hope, so a failure books the + // shared cooldown and any session left stale-'connected' by a half-open socket + // comes down; lease rotation clears it because armRetry owns its own retry. + private async recoverRelay(forceReplacement = false, ownsRecovery = false): Promise { + if (this.stopped || !this.foreground || !this.host.relay) { + return + } + if (this.operationInFlight) { + // Why: a 12s direct probe can own the mutex when a network handoff lands; + // afterProbe replays the queued replacement so the signal is never lost. + this.pendingReplace ||= forceReplacement && ownsRecovery + return + } + if (this.pendingReplace) { + this.pendingReplace = false + forceReplacement = true + ownsRecovery = true + } + // Why: connecting/handshaking is live direct progress; an unforced relay dial + // would race it before the grace timer has given direct its head start. + if (!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState())) { return } // Why: revival and lease timers can overlap resume failures; one shared cooldown // prevents PEER_DROPPED/LIMIT_EXCEEDED reconnect churn. if (this.relayReconnect.shouldDefer()) { + if (ownsRecovery) { + // Why: never tear down a session no dial has disproven — the intent stays + // queued so the armed retry runs forced once the cooldown lapses. + this.pendingReplace = true + } this.logRelay('recovery deferred by cooldown or gate') return } @@ -173,30 +260,33 @@ export class MobileEndpointSupervisor { : 'no relay credential bundle; slow reprobe armed' ) this.relayReconnect.armCredentialReprobe() + if (ownsRecovery) { + // Why: no dial happened — keep the session and the intent; the reprobe + // runs forced and replaces make-before-break once a credential exists. + this.pendingReplace = true + } return } - for (const credential of selection.credentials) { - const result = await this.tryRelayCredential(credential) - if (result.ok) { - retryAfterOperation = this.logical.getState() !== 'connected' - return - } - lastError = result.error - this.logRelay( - 'relay dial failed', - `${result.error.name}: ${String(result.error.message).slice(0, 80)}` - ) - if (this.relayReconnect.shouldTryGraceAfterRelayFailure(result.error)) { - // Why: a rejected version stays invalid; retry only the grace credential. - this.relayReconnect.recordRejectedCredential(credential.version) - } else { - break - } + const dialed = await this.sessionEstablisher.dialEligible(selection.credentials) + if (dialed.outcome === 'established') { + // Why: a fresh socket satisfies any replacement intent queued mid-dial. + this.pendingReplace = false + retryAfterOperation = this.logical.getState() !== 'connected' + return } + if (dialed.outcome === 'aborted') { + // Why: direct won the race or the supervisor went inactive — not a + // failure; booking backoff would delay the next genuine recovery. + return + } + lastError = dialed.error // Why: cleanup may happen while a relay dial is awaiting the network; // record its outcome without recreating a foreground retry timer. - const scheduleRetry = !forceReplacement && this.foreground && !this.stopped + const scheduleRetry = (!forceReplacement || ownsRecovery) && this.foreground && !this.stopped this.relayReconnect.registerFailure(lastError, scheduleRetry) + if (ownsRecovery) { + suspendRelayIfStillConnected(this.relayReconnect, this.logical) + } } finally { this.operationInFlight = false if (forceReplacement && this.relayRotationPending && !this.stopped && this.foreground) { @@ -209,77 +299,6 @@ export class MobileEndpointSupervisor { } } - private async tryRelayCredential(credential: { - token: string - version: number - }): Promise<{ ok: true } | { ok: false; error: Error }> { - const first = await this.openAndMigrateRelay(credential) - if (first.ok) { - return first - } - if (!isDirectorResolutionFailure(first.error) || !this.host.relay) { - return first - } - try { - const resolved = await this.dependencies.resolveRelay({ - relay: this.host.relay, - resumeToken: credential.token - }) - this.host = await persistRelayHost(this.host, resolved, this.dependencies.saveHost) - return await this.openAndMigrateRelay(credential) - } catch (error) { - return { ok: false, error: toError(error) } - } - } - - private async openAndMigrateRelay(credential: { - token: string - version: number - }): Promise<{ ok: true } | { ok: false; error: Error }> { - // Why: director resolution and grace fallback can finish after background/stop. - if (this.stopped || !this.foreground || !this.host.relay || !this.bundle) { - return { ok: false, error: new Error('relay state missing') } - } - const session = this.dependencies.openRelay( - this.host.relay, - credential, - `confirm-${encodeBase64Url(this.dependencies.randomBytes(16))}` - ) - try { - await this.logical.migrateTo(session, 'relay') - this.relayReconnect.setActiveSession(session) - if (!this.foreground) { - this.relayReconnect.suspendActiveRelay(this.logical) - } - this.relayRotationPending = false - this.hysteresis.recordMigration(this.dependencies.now()) - this.logRelay('runtime channel migrated to relay') - const confirmation = session.getResumeConfirmation() - if (confirmation) { - this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation) - // Why: the relay is already authenticated; a SecureStore failure must - // not open another socket or count against transport recovery backoff. - await this.dependencies.writeBundle(this.bundle).catch(() => {}) - } - // Why: async persistence can finish after stop/background; never recreate a stale timer. - // Why: rotate against the resume credential's expiry, never the hello's - // leaseExpiresAt — that field is the cell's ~10s attach-reservation - // deadline, and using it forced a session replacement every second. - // Why: renewed=false means a re-resume provably returns the same - // unchanged deadline — rotating then just churns one replacement per - // clamp floor until a fresh credential arrives over direct or disk. - this.leaseRotation.scheduleFromLease( - this.stopped || !this.foreground || confirmation?.renewed === false - ? null - : (confirmation?.resumeExpiresAt ?? session.getResumeExpiresAt()) - ) - this.directProbe.schedule() - return { ok: true } - } catch (error) { - return { ok: false, error: session.getFailure() ?? toError(error) } - } - } - private async rotateCredentialIfNeeded(force = false): Promise { if ( this.stopped || diff --git a/mobile/src/transport/mobile-relay-credential-rotation.ts b/mobile/src/transport/mobile-relay-credential-rotation.ts index 1905247255b..9b8a038e8e4 100644 --- a/mobile/src/transport/mobile-relay-credential-rotation.ts +++ b/mobile/src/transport/mobile-relay-credential-rotation.ts @@ -130,6 +130,38 @@ export function applyResumeConfirmation( return bundle } +// Applies a migrated session's resume confirmation to the durable bundle and +// derives the lease expiry to rotate against. +// Why: rotate against the resume credential's expiry, never the hello's +// leaseExpiresAt — that field is the cell's ~10s attach-reservation deadline, +// and using it forced a session replacement every second. +// Why: renewed=false means a re-resume provably returns the same unchanged +// deadline — rotating then just churns one replacement per clamp floor until a +// fresh credential arrives over direct or disk. +export async function persistResumeConfirmation(args: { + session: { + getResumeConfirmation(): DeviceResumeConfirmed | null + getResumeExpiresAt(): number | null + } + bundle: MobileRelayCredentialBundle + usedCredentialVersion: number + writeBundle: (bundle: MobileRelayCredentialBundle) => Promise +}): Promise<{ bundle: MobileRelayCredentialBundle; leaseExpiry: number | null }> { + const confirmation = args.session.getResumeConfirmation() + let bundle = args.bundle + if (confirmation) { + bundle = applyResumeConfirmation(bundle, args.usedCredentialVersion, confirmation) + // Why: the relay is already authenticated; a SecureStore failure must not + // open another socket or count against transport recovery backoff. + await args.writeBundle(bundle).catch(() => {}) + } + const leaseExpiry = + confirmation?.renewed === false + ? null + : (confirmation?.resumeExpiresAt ?? args.session.getResumeExpiresAt()) + return { bundle, leaseExpiry } +} + async function getEndpoints(client: RpcClient, installReqId: string) { const response = await client.sendRequest('pairing.getEndpoints', { installReqId }) if (!response.ok) { diff --git a/mobile/src/transport/mobile-relay-direct-grace-timer.ts b/mobile/src/transport/mobile-relay-direct-grace-timer.ts new file mode 100644 index 00000000000..df3c1ba1428 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-grace-timer.ts @@ -0,0 +1,48 @@ +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' + +// Why: on a black-holed LAN endpoint the direct dial sits in 'connecting' for the +// whole 12s connect timeout (rpc-client CONNECT_TIMEOUT_MS), and relay recovery +// cannot even start meanwhile because connecting/handshaking count as live direct +// progress. Happy eyeballs: give direct this much of a head start, then race the +// relay dial — migrateTo hands the logical client to whichever authenticates first. +const DIRECT_DIAL_GRACE_MS = 2500 + +type DirectGraceTimerDependencies = { + setTimer: typeof setTimeout + clearTimer: typeof clearTimeout +} + +// One-shot timer that releases the relay dial when the direct dial has not +// authenticated within the grace. The supervisor arms it at start and on +// foreground restore, and clears it on connect, background, and stop. +export class MobileRelayDirectGraceTimer { + private timer: ReturnType | null = null + + constructor( + private readonly dependencies: DirectGraceTimerDependencies, + private readonly logical: StableLogicalRpcClient, + private readonly dialRelay: () => void + ) {} + + // No-op unless the direct dial is still unauthenticated, so a healthy LAN and + // an already-failed direct path (recovery owns that) never open a relay socket. + arm(): void { + const state = this.logical.getState() + if (this.timer || (state !== 'connecting' && state !== 'handshaking')) { + return + } + this.timer = this.dependencies.setTimer(() => { + this.timer = null + if (this.logical.getState() !== 'connected') { + this.dialRelay() + } + }, DIRECT_DIAL_GRACE_MS) + } + + clear(): void { + if (this.timer) { + this.dependencies.clearTimer(this.timer) + this.timer = null + } + } +} diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts b/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts index 1cd57cfcb72..be0e1ccfb0c 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts @@ -40,6 +40,12 @@ export class MobileRelayDirectUpgradeController { } } + // No relay session exists yet; the direct socket already self-probes on + // notifyForeground, so a nudge only retries the pending upgrade. + nudge(): void { + this.setForeground(true) + } + stop(): void { this.stopped = true this.unsubscribe?.() diff --git a/mobile/src/transport/mobile-relay-focus-probe.ts b/mobile/src/transport/mobile-relay-focus-probe.ts new file mode 100644 index 00000000000..2671a62cd67 --- /dev/null +++ b/mobile/src/transport/mobile-relay-focus-probe.ts @@ -0,0 +1,53 @@ +import { + isLogicalClientCutoverError, + type StableLogicalRpcClient +} from './stable-logical-rpc-client' + +// Why: a probe must fail well before the 12s connect ceiling so a dead socket +// still recovers in a few seconds, but outlast one slow cellular RTT. +const PROBE_TIMEOUT_MS = 4_000 +// Why: focus fires per navigation event; one liveness answer covers them all. +const PROBE_MIN_INTERVAL_MS = 10_000 + +// Cheap liveness check for a focus nudge on an active relay: a healthy session +// answers and nothing visible changes; only a dead one is handed to onDead. +export class MobileRelayFocusProbe { + private lastProbeAt = 0 + + constructor( + private readonly args: { + logical: StableLogicalRpcClient + now: () => number + canProbe: () => boolean + onDead: (detail: string) => void + } + ) {} + + async probe(): Promise { + const { logical, now } = this.args + const at = now() + if ( + !this.args.canProbe() || + at - this.lastProbeAt < PROBE_MIN_INTERVAL_MS || + logical.getActivePath() !== 'relay' || + logical.getState() !== 'connected' + ) { + return + } + this.lastProbeAt = at + const generation = logical.getGeneration() + try { + await logical.sendRequest('status.get', null, { timeoutMs: PROBE_TIMEOUT_MS }) + } catch (error) { + if ( + isLogicalClientCutoverError(error) || + !this.args.canProbe() || + logical.getGeneration() !== generation || + logical.getActivePath() !== 'relay' + ) { + return + } + this.args.onDead(String((error as Error).message).slice(0, 80)) + } + } +} diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index 41d53189880..96d71b37680 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => { expect(saved.metadata.authorizationMode).toBe('authenticated-direct') expect(deps.updateJournal).toHaveBeenCalledTimes(2) }) + // Why: a journal stranded by a relay outage used to block every later pairing + // with "recovery pending" forever, because recovery only ever deferred. + it('abandons a journal once its invite expired and no credential can reconcile', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned') + expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId) + }) + + it('keeps a just-expired journal so a brief outage cannot discard it', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + // Why: a committed install whose local persistence failed is the one case the + // journal must survive — it is the only record left to retry the write from. + it('keeps a journal when the server committed but the local write failed', async () => { + const saved = journal() + const directInstalled = installed(saved, 'authenticated-direct') + const committed = client(async () => + response(endpoints(saved, { state: 'committed', result: directInstalled })) + ) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1, + writeCredentialBundle: vi.fn(async () => { + throw new Error('keychain unavailable') + }) + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index 0d5ba7e5e07..26dc08620de 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -27,7 +27,7 @@ import { import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import type { HostProfile, RpcResponse } from './types' -export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' +export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned' type RecoveryDependencies = { loadJournal: typeof loadMobileRelayPairingJournal @@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = { platform: Platform.OS } +// One full invite lifetime past expiry, so a momentary outage never discards a +// journal that a later launch could still reconcile. +const ABANDON_GRACE_MS = 10 * 60 * 1000 + let recoveryPromise: Promise | null = null export function recoverMobileRelayPairing( @@ -96,6 +100,10 @@ async function runRecovery( } const credentials = recoveryCredentials(journal, bundle, dependencies.now()) + // Why: publishCommitted runs inside the catch below, so a failed local write + // of an authoritatively committed install must not look like "nothing to + // reconcile" — that journal is the only record left to retry the write from. + let observedCommitted = false for (const credential of credentials) { let client: PairingCandidateClient | null = null try { @@ -113,6 +121,7 @@ async function runRecovery( }) const endpoints = await getRecoveryStatus(client, journal, credential.kind) if (endpoints.installStatus?.state === 'committed') { + observedCommitted = true await publishCommitted(journal, endpoints, dependencies) return 'recovered' } @@ -128,6 +137,7 @@ async function runRecovery( ) const reconciled = await getRecoveryStatus(client, journal, 'invite') assertCommitted(reconciled, installed) + observedCommitted = true await publishCommitted(journal, reconciled, dependencies) return 'recovered' } @@ -138,6 +148,19 @@ async function runRecovery( client?.close() } } + // Why: past invite expiry no credential can still establish what happened, so + // retaining the journal cannot reconcile anything — it only fails every later + // pairing with "recovery pending" forever. Re-pairing mints a fresh device and + // any uncommitted server-side install expires on its own. The extra invite + // lifetime of slack keeps a brief relay outage from discarding a journal whose + // resume credential would have reconciled it on the next launch. + if ( + !observedCommitted && + journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now() + ) { + await dependencies.clearJournal(journal.metadata.journalId).catch(() => {}) + return 'abandoned' + } return 'deferred' } diff --git a/mobile/src/transport/mobile-relay-physical-client.test.ts b/mobile/src/transport/mobile-relay-physical-client.test.ts index e4aaa0fe4f8..77972cc6dfd 100644 --- a/mobile/src/transport/mobile-relay-physical-client.test.ts +++ b/mobile/src/transport/mobile-relay-physical-client.test.ts @@ -27,6 +27,7 @@ vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ })) import { connectMobileRelayForPairing, RelayOuterError } from './mobile-relay-physical-client' +import type { ConnectionLogEntry } from './types' class FakeSocket { readonly OPEN = 1 @@ -132,4 +133,58 @@ describe('mobile relay physical pairing client', () => { await expect(status).rejects.toEqual(new RelayOuterError(4404)) expect(fakes.start).not.toHaveBeenCalled() }) + + it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => { + const socket = new FakeSocket() + const entries: ConnectionLogEntry[] = [] + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket, + onLog: (entry) => entries.push(entry) + }) + socket.onopen?.() + socket.receive( + JSON.stringify({ + type: 'relay-hello', + ok: true, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 60_000 + }) + ) + await vi.waitFor(() => expect(fakes.start).toHaveBeenCalledOnce()) + fakes.channelOptions!.onAuthenticated() + client.close() + + expect(entries.map((entry) => `${entry.level}|${entry.message}|${entry.detail}`)).toEqual([ + 'info|Relay: dialing cell|relay-c1.onorca.dev', + 'info|Relay: cell socket open|Sending relay credential', + 'info|Relay: cell accepted credential|Starting E2EE handshake', + 'success|Relay: authenticated|Channel ready for RPC', + 'info|Relay: pairing socket closed|relay-c1.onorca.dev' + ]) + expect(JSON.stringify(entries)).not.toContain(relay.inviteToken) + }) + + it('logs the relay close code when the cell rejects the credential', async () => { + const socket = new FakeSocket() + const entries: ConnectionLogEntry[] = [] + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket, + onLog: (entry) => entries.push(entry) + }) + const status = client.sendRequest('status.get') + socket.receive(JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 })) + + await expect(status).rejects.toEqual(new RelayOuterError(4404)) + expect(entries.at(-1)).toMatchObject({ + level: 'warn', + message: 'Relay: pairing socket closed', + detail: 'relay close code 4404' + }) + }) }) diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index acf264b8c73..37b5d64857e 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -2,8 +2,10 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offe import { RelayPhoneHelloSchema } from '../../../src/shared/mobile-relay-phone-protocol' import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' +import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log' import { isRpcResponse } from './rpc-response-shape' -import type { RpcResponse } from './types' +import { redactSocketEndpoint } from './socket-event-debug' +import type { ConnectionLogSink, RpcResponse } from './types' import { websocketPayloadToUint8 } from './websocket-payload-bytes' export { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayOuterError } from './mobile-relay-e2ee-link' @@ -27,9 +29,13 @@ export function connectMobileRelayForPairing(args: { expectedCredentialKind?: 'invite' | 'resume' requestTimeoutMs?: number createSocket?: (url: string) => WebSocket + onLog?: ConnectionLogSink }): PairingCandidateClient { const requestTimeoutMs = args.requestTimeoutMs ?? 30_000 const socketUrl = relayPhoneWebSocketUrl(args.relay) + const log = createPairingRelayLogger(args.onLog) + const cellHost = redactSocketEndpoint(socketUrl) + log('info', 'Relay: dialing cell', cellHost) const socket = (args.createSocket ?? ((url) => new WebSocket(url)))(socketUrl) const session = MobileE2EEV2ClientSession.create({ desktopPublicKeyB64: args.desktopPublicKeyB64, @@ -39,6 +45,7 @@ export function connectMobileRelayForPairing(args: { const pending = new Map() let requestCounter = 0 let closed = false + let intentionallyClosed = false let outerReady = false let authenticated = false let resolveAuthenticated!: () => void @@ -54,6 +61,7 @@ export function connectMobileRelayForPairing(args: { decodeBinary: websocketPayloadToUint8, onAuthenticated: () => { authenticated = true + log('success', 'Relay: authenticated', 'Channel ready for RPC') resolveAuthenticated() }, onText: (plaintext) => { @@ -78,6 +86,7 @@ export function connectMobileRelayForPairing(args: { }) socket.onopen = () => { + log('info', 'Relay: cell socket open', 'Sending relay credential') socket.send( JSON.stringify({ type: 'relay-auth', @@ -126,6 +135,7 @@ export function connectMobileRelayForPairing(args: { throw new Error('relay credential resolved as an unexpected credential kind') } outerReady = true + log('info', 'Relay: cell accepted credential', 'Starting E2EE handshake') channel.start() } @@ -134,6 +144,11 @@ export function connectMobileRelayForPairing(args: { return } closed = true + if (intentionallyClosed) { + log('info', 'Relay: pairing socket closed', cellHost) + } else { + log('warn', 'Relay: pairing socket closed', pairingRelayErrorDetail(error)) + } channel.dispose() rejectAuthenticated(error) for (const request of pending.values()) { @@ -166,7 +181,10 @@ export function connectMobileRelayForPairing(args: { } }) }, - close: () => fail(new Error('relay pairing client closed')) + close: () => { + intentionallyClosed = true + fail(new Error('relay pairing client closed')) + } } } diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index 9ec5c1398bc..2955819ea4a 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -8,7 +8,7 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel import { RelayOuterError } from './mobile-relay-e2ee-link' import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' -import type { ConnectionState } from './types' +import type { ConnectionState, ForegroundNudgeReason } from './types' export type RelayReconnectDependencies = { now: () => number @@ -50,18 +50,31 @@ export class RelayReconnectController { } else if (this.recoveryGate === 'external-signal') { this.liftGate() } - if ( - wasForeground && - this.recoveryGate !== 'fresh-credential' && - logical.getState() === 'connected' - ) { - // Why: a network handoff can leave the relay half-open without publishing a close. - this.suspendActiveRelay(logical) - } // Why: revival nudges must honor failure cooldowns even when lease rotation is pending. this.onRetry() } + // Classifies a nudge that arrives while already foreground. A healthy relay is + // never suspended here: focus/app-resume probe it, a network change replaces it + // make-before-break — suspending first was the grey-blink bug (S2). + handleActiveNudge( + logical: StableLogicalRpcClient, + reason: ForegroundNudgeReason + ): 'probe' | 'replace' | 'recover' { + if (this.recoveryGate === 'external-signal') { + this.liftGate() + } + if ( + this.recoveryGate !== 'fresh-credential' && + logical.getActivePath() === 'relay' && + logical.getState() === 'connected' + ) { + return reason === 'network-change' ? 'replace' : 'probe' + } + this.onRetry() + return 'recover' + } + handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): void { if (!this.needsRecovery(state)) { return diff --git a/mobile/src/transport/mobile-relay-session-establisher.ts b/mobile/src/transport/mobile-relay-session-establisher.ts new file mode 100644 index 00000000000..d4fa52854a1 --- /dev/null +++ b/mobile/src/transport/mobile-relay-session-establisher.ts @@ -0,0 +1,137 @@ +import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract' +import { + dialRelayThroughDirectorFallback, + encodeBase64Url, + RelayDialAbortedError, + toError +} from './mobile-endpoint-supervisor-support' +import { persistResumeConfirmation } from './mobile-relay-credential-rotation' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' +import type { HostProfile } from './types' + +type EstablishResult = { ok: true } | { ok: false; error: Error } + +function directWon(logical: StableLogicalRpcClient): boolean { + return logical.getActivePath() !== 'relay' && logical.getState() === 'connected' +} + +// Turns one relay credential into the active runtime session: resolve the cell +// assignment if the director rejects the cached one, open the cell socket, +// migrate the logical client onto it, then persist the resume confirmation and +// re-arm the supervisor's timers. +export class MobileRelaySessionEstablisher { + constructor( + private readonly args: { + logical: StableLogicalRpcClient + controller: RelayReconnectController + openRelay: MobileEndpointSupervisorDependencies['openRelay'] + randomBytes: (length: number) => Uint8Array + writeBundle: (bundle: MobileRelayCredentialBundle) => Promise + isActive: () => boolean + isForeground: () => boolean + relay: () => HostProfile['relay'] + resolveRelay: MobileEndpointSupervisorDependencies['resolveRelay'] + persistResolvedRelay: (resolved: MobileRelayEndpoint) => Promise + bundle: () => MobileRelayCredentialBundle | null + adoptBundle: (bundle: MobileRelayCredentialBundle) => void + // Hysteresis stamp + rotation-pending clear + recovery log line. + recordMigration: () => void + // Owns the stopped/background null-out so a late resolve never re-arms a stale timer. + scheduleLease: (expiry: number | null) => void + scheduleDirectProbe: () => void + onBookkeepingError: (error: Error) => void + onDialFailure: (error: Error) => void + } + ) {} + + // Tries each eligible credential until one establishes. Only a grace-repairable + // failure (BAD_OUTER_CREDENTIAL) moves on to the next credential. + async dialEligible( + credentials: { token: string; version: number }[] + ): Promise< + { outcome: 'established' } | { outcome: 'aborted' } | { outcome: 'failed'; error: Error | null } + > { + let lastError: Error | null = null + for (const credential of credentials) { + const result = await this.dial(credential) + if (result.ok) { + return { outcome: 'established' } + } + if (result.error instanceof RelayDialAbortedError) { + return { outcome: 'aborted' } + } + lastError = result.error + this.args.onDialFailure(result.error) + if (!this.args.controller.shouldTryGraceAfterRelayFailure(result.error)) { + break + } + // Why: a rejected version stays invalid; retry only the grace credential. + this.args.controller.recordRejectedCredential(credential.version) + } + return { outcome: 'failed', error: lastError } + } + + // One credential attempt: a director-class failure re-resolves the cell + // assignment, persists it, and dials once more against the authoritative target. + dial(credential: { token: string; version: number }): Promise { + return dialRelayThroughDirectorFallback({ + resumeToken: credential.token, + relay: this.args.relay, + dial: () => this.establish(credential), + resolveRelay: this.args.resolveRelay, + persistResolvedRelay: this.args.persistResolvedRelay + }) + } + + private async establish(credential: { + token: string + version: number + }): Promise { + const { args } = this + const relay = args.relay() + const bundle = args.bundle() + // Why: director resolution and grace fallback can finish after background/stop. + if (!args.isActive() || !relay || !bundle) { + return { ok: false, error: new RelayDialAbortedError() } + } + const session = args.openRelay( + relay, + credential, + `confirm-${encodeBase64Url(args.randomBytes(16))}` + ) + try { + // Why: if an authenticated non-relay session appears while this dial is in + // flight (the grace race), withdraw instead of cutting over the winner. + await args.logical.migrateTo(session, 'relay', undefined, () => directWon(args.logical)) + } catch (error) { + if (directWon(args.logical)) { + return { ok: false, error: new RelayDialAbortedError() } + } + return { ok: false, error: session.getFailure() ?? toError(error) } + } + args.controller.setActiveSession(session) + if (!args.isForeground()) { + args.controller.suspendActiveRelay(args.logical) + } + args.recordMigration() + try { + const applied = await persistResumeConfirmation({ + session, + bundle, + usedCredentialVersion: credential.version, + writeBundle: args.writeBundle + }) + args.adoptBundle(applied.bundle) + args.scheduleLease(applied.leaseExpiry) + } catch (error) { + // Why: the session is live and registered — reporting bookkeeping as a dial + // failure would book backoff against it and can suspend the healthy session. + args.onBookkeepingError(toError(error)) + } + args.scheduleDirectProbe() + return { ok: true } + } +} diff --git a/mobile/src/transport/pairing-relay-candidate.test.ts b/mobile/src/transport/pairing-relay-candidate.test.ts index 742af991c7e..07d315a487d 100644 --- a/mobile/src/transport/pairing-relay-candidate.test.ts +++ b/mobile/src/transport/pairing-relay-candidate.test.ts @@ -3,6 +3,7 @@ import type { PairingCandidateClient } from './mobile-relay-physical-client' import { RelayOuterError } from './mobile-relay-physical-client' import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import type { ConnectionLogEntry } from './types' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) vi.mock('expo-crypto', () => ({ @@ -163,4 +164,76 @@ describe('recovering pairing relay candidate', () => { expect(resolveDirector).toHaveBeenCalledTimes(3) expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([50, 100, 200]) }) + + it('narrates every director attempt, cell move and backoff to the pairing log', async () => { + const entries: ConnectionLogEntry[] = [] + const stale = client(Promise.reject(new Error('HTTP 503'))) + const target = client(Promise.resolve(success())) + const resolveDirector = vi + .fn() + .mockRejectedValueOnce(new Error('HTTP 504')) + .mockImplementationOnce(async (relay) => ({ + ...relay, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8 + })) + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => (connects++ === 0 ? stale : target), + resolveDirector, + persistMove: vi.fn(async () => {}), + now: () => 1, + random: () => 0.5, + sleep: async () => {}, + onLog: (entry) => entries.push(entry) + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(entries.map((entry) => `${entry.level}|${entry.message}`)).toEqual([ + 'warn|Relay: cell dial failed', + 'info|Relay: resolving director (attempt 1/3)', + 'warn|Relay: recovery attempt 1 failed', + 'info|Relay: backing off 50ms', + 'info|Relay: resolving director (attempt 2/3)', + 'info|Relay: cell moved', + 'info|Relay: backing off 100ms' + ]) + expect(entries[0]!.detail).toBe('Error: HTTP 503') + expect(entries[1]!.detail).toBe('relay.onorca.dev') + expect(entries[2]!.detail).toBe('Error: HTTP 504') + expect(entries[5]!.detail).toBe('relay-c1.onorca.dev → relay-c2.onorca.dev') + expect(new Set(entries.map((entry) => entry.id)).size).toBe(entries.length) + }) + + it('reports the final give-up once the recovery budget is spent', async () => { + const entries: ConnectionLogEntry[] = [] + const stale = client(Promise.reject(new RelayOuterError(4409))) + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => stale, + resolveDirector: vi.fn(async () => { + throw new Error('relay director resolution timed out') + }), + persistMove: vi.fn(async () => {}), + now: () => 1, + random: () => 0, + sleep: async () => {}, + maxRecoveryAttempts: 2, + onLog: (entry) => entries.push(entry) + }) + + await expect(candidate.sendRequest('status.get')).rejects.toThrow(/timed out/) + expect(entries[0]).toMatchObject({ + level: 'warn', + message: 'Relay: cell dial failed', + detail: 'relay close code 4409' + }) + expect(entries.filter((entry) => entry.level === 'error')).toEqual([ + expect.objectContaining({ + message: 'Relay: recovery gave up', + detail: 'after 2 attempt(s)' + }) + ]) + }) }) diff --git a/mobile/src/transport/pairing-relay-candidate.ts b/mobile/src/transport/pairing-relay-candidate.ts index 50a7108b5f8..2293ce1855f 100644 --- a/mobile/src/transport/pairing-relay-candidate.ts +++ b/mobile/src/transport/pairing-relay-candidate.ts @@ -1,19 +1,24 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client' +import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log' +import { redactSocketEndpoint } from './socket-event-debug' +import type { ConnectionLogSink } from './types' export function createRecoveringPairingRelayCandidate(args: { journal: MobileRelayPairingJournal - connect: (relay: PairingRelay) => PairingCandidateClient + connect: (relay: PairingRelay, onLog?: ConnectionLogSink) => PairingCandidateClient resolveDirector: (relay: PairingRelay) => Promise persistMove: (relay: PairingRelay) => Promise now: () => number random?: () => number sleep?: (delayMs: number) => Promise maxRecoveryAttempts?: number + onLog?: ConnectionLogSink }): PairingCandidateClient { + const log = createPairingRelayLogger(args.onLog) let relay = pairingRelayFromJournal(args.journal) - let client = args.connect(relay) + let client = args.connect(relay, args.onLog) let closed = false return { @@ -43,32 +48,52 @@ export function createRecoveringPairingRelayCandidate(args: { const random = args.random ?? Math.random const sleep = args.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) + const backOff = async (attempt: number): Promise => { + const capMs = Math.min(2_000, 100 * 2 ** attempt) + const delayMs = Math.floor(random() * (capMs + 1)) + if (delayMs > 0) { + log('info', `Relay: backing off ${delayMs}ms`) + } + await sleep(delayMs) + } let lastError = initialError + log('warn', 'Relay: cell dial failed', pairingRelayErrorDetail(initialError)) for (let attempt = 0; attempt < maxAttempts; attempt += 1) { if (closed || relay.inviteExpiresAt <= args.now()) { + log('error', 'Relay: recovery gave up', closed ? 'pairing cancelled' : 'invite expired') throw lastError } + log( + 'info', + `Relay: resolving director (attempt ${attempt + 1}/${maxAttempts})`, + redactSocketEndpoint(relay.directorUrl) + ) try { const moved = await args.resolveDirector(relay) // Why: the authenticated newer assignment must be durable before a // target dial so a crash cannot revert to the known-stale cell. await args.persistMove(moved) + log( + 'info', + 'Relay: cell moved', + `${redactSocketEndpoint(relay.cellUrl)} → ${redactSocketEndpoint(moved.cellUrl)}` + ) client.close() relay = moved - const capMs = Math.min(2_000, 100 * 2 ** attempt) - await sleep(Math.floor(random() * (capMs + 1))) + await backOff(attempt) if (closed) { throw new Error('relay pairing client closed') } - client = args.connect(relay) + client = args.connect(relay, args.onLog) return await client.sendRequest(method, params) } catch (error) { lastError = error + log('warn', `Relay: recovery attempt ${attempt + 1} failed`, pairingRelayErrorDetail(error)) if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) { + log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`) throw error } - const capMs = Math.min(2_000, 100 * 2 ** attempt) - await sleep(Math.floor(random() * (capMs + 1))) + await backOff(attempt) } } throw lastError diff --git a/mobile/src/transport/pairing-relay-log.ts b/mobile/src/transport/pairing-relay-log.ts new file mode 100644 index 00000000000..78653bddd12 --- /dev/null +++ b/mobile/src/transport/pairing-relay-log.ts @@ -0,0 +1,29 @@ +import { RelayOuterError } from './mobile-relay-e2ee-link' +import type { ConnectionLogLevel, ConnectionLogSink } from './types' + +export type PairingRelayLogger = ( + level: ConnectionLogLevel, + message: string, + detail?: string +) => void + +// Why: every relay dial and recovery attempt builds its own logger but they all +// feed one list, so the id sequence has to be process-wide to stay a unique key. +let sequence = 0 + +export function createPairingRelayLogger(onLog?: ConnectionLogSink): PairingRelayLogger { + if (!onLog) { + return () => {} + } + return (level, message, detail) => { + onLog({ id: `relay-pair-log-${++sequence}`, ts: Date.now(), level, message, detail }) + } +} + +export function pairingRelayErrorDetail(error: unknown): string { + if (error instanceof RelayOuterError) { + return `relay close code ${error.code}` + } + const failure = error instanceof Error ? error : new Error(String(error)) + return `${failure.name}: ${String(failure.message).slice(0, 80)}` +} diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts index 782c02aed50..e5951dbdc38 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -3,7 +3,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' import { racePairingCandidates } from './pairing-candidate-race' import { startPreProfilePairing } from './pre-profile-pairing-coordinator' -import type { HostProfile, PairingOffer, RpcResponse } from './types' +import type { ConnectionLogEntry, HostProfile, PairingOffer, RpcResponse } from './types' import type { RpcClient } from './rpc-client' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) @@ -53,6 +53,44 @@ function fakeClient(responses: RpcResponse[]) { } as unknown as RpcClient } +function relayProvisioningClient(getJournal: () => MobileRelayPairingJournal) { + return { + sendRequest: vi.fn(async (method: string) => { + if (method === 'status.get') { + return success({ path: 'relay' }) + } + const installed = { + v: 1 as const, + reqId: getJournal().metadata.installReqId, + authorizationMode: 'relay-basis' as const, + currentVersion: 1, + resumeExpiresAt: now + 86_400_000 + } + if (method === 'pairing.provisionRelay') { + return success(installed) + } + return success({ + v: 1, + relay: { + v: 1, + directorUrl: relayOffer.relay!.directorUrl, + cellUrl: relayOffer.relay!.cellUrl, + assignmentEpoch: 7, + relayHostId: relayOffer.relay!.relayHostId, + e2eeFraming: 2 + }, + installStatus: { + v: 1, + reqId: getJournal().metadata.installReqId, + state: 'committed', + result: installed + } + }) + }), + close: vi.fn() + } as unknown as RpcClient +} + function dependencies(client: RpcClient, events: string[]) { const unavailableRelay = fakeClient([]) ;(unavailableRelay.sendRequest as ReturnType).mockRejectedValue( @@ -280,41 +318,7 @@ describe('pre-profile pairing coordinator', () => { const direct = fakeClient([]) ;(direct.sendRequest as ReturnType).mockRejectedValue(new Error('LAN down')) let journal: MobileRelayPairingJournal | null = null - const relay = { - sendRequest: vi.fn(async (method: string) => { - if (method === 'status.get') { - return success({ path: 'relay' }) - } - const installed = { - v: 1 as const, - reqId: journal!.metadata.installReqId, - authorizationMode: 'relay-basis' as const, - currentVersion: 1, - resumeExpiresAt: now + 86_400_000 - } - if (method === 'pairing.provisionRelay') { - return success(installed) - } - return success({ - v: 1, - relay: { - v: 1, - directorUrl: relayOffer.relay!.directorUrl, - cellUrl: relayOffer.relay!.cellUrl, - assignmentEpoch: 7, - relayHostId: relayOffer.relay!.relayHostId, - e2eeFraming: 2 - }, - installStatus: { - v: 1, - reqId: journal!.metadata.installReqId, - state: 'committed', - result: installed - } - }) - }), - close: vi.fn() - } as unknown as RpcClient + const relay = relayProvisioningClient(() => journal!) const deps = dependencies(direct, []) deps.connectRelay.mockReturnValue(relay) deps.saveJournal.mockImplementation(async (value) => { @@ -338,6 +342,46 @@ describe('pre-profile pairing coordinator', () => { ) }) + it('streams the relay path and the winning path into the pairing log', async () => { + const entries: ConnectionLogEntry[] = [] + const direct = fakeClient([]) + ;(direct.sendRequest as ReturnType).mockRejectedValue(new Error('LAN down')) + let journal: MobileRelayPairingJournal | null = null + const relay = relayProvisioningClient(() => journal!) + const deps = dependencies(direct, []) + deps.saveJournal.mockImplementation(async (value) => { + journal = value + }) + // Why: stands in for the physical client's own dial line, proving the sink + // actually reaches connectMobileRelayForPairing and not just the candidate. + deps.connectRelay.mockImplementation((connectArgs) => { + connectArgs.onLog?.({ + id: 'relay-dial', + ts: now, + level: 'info', + message: 'Relay: dialing cell', + detail: 'relay-c1.onorca.dev' + }) + return relay + }) + + const attempt = startPreProfilePairing({ + offer: relayOffer, + timeoutMs: 5_000, + connectOptions: { onLog: (entry) => entries.push(entry) }, + dependencies: deps + }) + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + + expect(entries.map((entry) => entry.message)).toEqual([ + 'Relay: pairing candidate started', + 'Relay: dialing cell', + 'Pairing path selected' + ]) + expect(entries[0]!.detail).toBe('relay-c1.onorca.dev') + expect(entries[2]).toMatchObject({ level: 'success', detail: 'winner: relay' }) + }) + it('cancels the disposable physical client without publishing a host', async () => { let resolveStatus!: (response: RpcResponse) => void const status = new Promise((resolve) => { diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index 82bd989be03..7518e68e2b7 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -28,6 +28,8 @@ import { import { racePairingCandidates, type PairingCandidate } from './pairing-candidate-race' import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' +import { createPairingRelayLogger } from './pairing-relay-log' +import { redactSocketEndpoint } from './socket-event-debug' export type PreProfilePairingAttempt = { readonly result: Promise<{ hostId: string }> @@ -157,14 +159,21 @@ async function runPairing( ) clients.add(directClient) const candidates: PairingCandidate[] = [{ path: 'direct', client: directClient }] + const log = createPairingRelayLogger(connectOptions?.onLog) if (journal) { + log( + 'info', + 'Relay: pairing candidate started', + redactSocketEndpoint(journal.metadata.relay.cellUrl) + ) const relayClient = createRecoveringPairingRelayCandidate({ journal, - connect: (relay) => + connect: (relay, onLog) => dependencies.connectRelay({ relay, deviceToken: offer.deviceToken, - desktopPublicKeyB64: offer.publicKeyB64 + desktopPublicKeyB64: offer.publicKeyB64, + onLog }), resolveDirector: (relay) => dependencies.resolveInviteDirector({ relay }), persistMove: async (relay) => { @@ -181,12 +190,14 @@ async function runPairing( } await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata) }, - now: dependencies.now + now: dependencies.now, + onLog: connectOptions?.onLog }) clients.add(relayClient) candidates.push({ path: 'relay', client: relayClient }) } const winner = await racePairingCandidates(candidates) + log('success', 'Pairing path selected', `winner: ${winner.path}`) assertActive(isDisposed) if (!journal) { diff --git a/mobile/src/transport/replacement-session-authentication.ts b/mobile/src/transport/replacement-session-authentication.ts new file mode 100644 index 00000000000..0ba54a9d611 --- /dev/null +++ b/mobile/src/transport/replacement-session-authentication.ts @@ -0,0 +1,44 @@ +import type { RpcClient } from './rpc-client' + +// Why: a migration must not cut over to a session that has only opened a socket — the +// replacement has to reach 'connected' (E2EE authenticated) first, and a relay dial can +// sit in handshaking for seconds, so the wait is bounded by the caller's timeout. +export function waitForAuthenticated(session: RpcClient, timeoutMs: number): Promise { + if (session.getState() === 'connected') { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + let settled = false + let unsubscribe: (() => void) | null = null + // Why: armed before subscribing — a synchronous notification during registration + // must find a timer to clear, or a settled wait leaves it running for 12s. + const timer = setTimeout(() => { + finish() + reject(new Error('replacement session authentication timed out')) + }, timeoutMs) + unsubscribe = session.onStateChange((state) => { + if (state === 'connected') { + finish() + resolve() + } else if (state === 'auth-failed' || state === 'disconnected') { + finish() + reject(new Error(`replacement session ${state}`)) + } + }) + if (settled) { + // Why: the notification fired inside onStateChange, before we held the handle. + unsubscribe() + unsubscribe = null + } + + function finish(): void { + if (settled) { + return + } + settled = true + clearTimeout(timer) + unsubscribe?.() + unsubscribe = null + } + }) +} diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 07891e84d85..9b0afe937f3 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -3,7 +3,8 @@ import type { RpcSuccess, ConnectionState, ConnectionLogLevel, - ConnectionLogSink + ConnectionLogSink, + ForegroundNudgeReason } from './types' import { generateKeyPair, @@ -105,7 +106,8 @@ export type RpcClient = { getLastConnectedAt: () => number | null onStateChange: (listener: (state: ConnectionState) => void) => () => void // Why: app-resume hook — iOS/Android can kill the TCP path while backgrounded; call on AppState 'active' to recover. - notifyForeground: () => void + // The reason routes relay handling (probe vs replace); the direct socket probes regardless. + notifyForeground: (reason?: ForegroundNudgeReason) => void close: () => void } diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index e0b8cb5eefe..2171a690b59 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -184,6 +184,134 @@ describe('stable logical RPC client', () => { ) }) + it('publishes the replacement dial phases while the client is suspended', async () => { + const oldSession = new FakeSession('connected') + const replacement = new FakeSession('connecting') + const client = createStableLogicalRpcClient(oldSession, 'relay') + const states: ConnectionState[] = [] + client.onStateChange((next) => states.push(next)) + + client.suspendActiveSession() + expect(client.getPendingPath()).toBeNull() + + const migrating = client.migrateTo(replacement, 'relay') + replacement.setState('connecting') + expect(client.getState()).toBe('connecting') + expect(client.getPendingPath()).toBe('relay') + replacement.setState('handshaking') + expect(client.getState()).toBe('handshaking') + replacement.setState('connected') + await migrating + + expect(states).toEqual(['disconnected', 'connecting', 'handshaking', 'connected']) + expect(client.getPendingPath()).toBeNull() + expect(client.getActivePath()).toBe('relay') + }) + + // A replacement relay session that retries internally publishes 'reconnecting' as one of + // its own dial phases. Forwarding it is the point: amber "Reconnecting…" beats the grey + // the suspended client would otherwise hold for the whole dial. + it('forwards a reconnecting phase published by the dialing session itself', async () => { + const oldSession = new FakeSession('connected') + const replacement = new FakeSession('connecting') + const client = createStableLogicalRpcClient(oldSession, 'relay') + client.suspendActiveSession() + const states: ConnectionState[] = [] + client.onStateChange((next) => states.push(next)) + + const migrating = client.migrateTo(replacement, 'relay') + replacement.setState('connecting') + replacement.setState('reconnecting') + expect(client.getState()).toBe('reconnecting') + replacement.setState('handshaking') + replacement.setState('connected') + await migrating + + expect(states).toEqual(['connecting', 'reconnecting', 'handshaking', 'connected']) + }) + + // The dominant relay case: the direct dial failed, so the client sits in 'reconnecting' + // (never 'disconnected') while its retry loop lives. The dot is already amber there, so + // nothing is forwarded — but the pending path must still name what is being dialed. + it('names a relay dial started from reconnecting without disturbing the state', async () => { + const direct = new FakeSession('connected') + const replacement = new FakeSession('connecting') + const client = createStableLogicalRpcClient(direct, 'lan') + direct.setState('reconnecting') + const states: ConnectionState[] = [] + client.onStateChange((next) => states.push(next)) + + const migrating = client.migrateTo(replacement, 'relay') + expect(client.getPendingPath()).toBe('relay') + replacement.setState('connecting') + replacement.setState('handshaking') + + // The still-bound direct session keeps cycling; the forwarder must not fight it. + expect(client.getState()).toBe('reconnecting') + expect(states).toEqual([]) + + replacement.setState('connected') + await migrating + + expect(states).toEqual(['connected']) + expect(client.getPendingPath()).toBeNull() + expect(client.getActivePath()).toBe('relay') + }) + + it('drops the pending path when the previous session recovers mid-dial', async () => { + const direct = new FakeSession('connected') + const replacement = new FakeSession('connecting') + const client = createStableLogicalRpcClient(direct, 'lan') + direct.setState('reconnecting') + + const migrating = client.migrateTo(replacement, 'relay') + expect(client.getPendingPath()).toBe('relay') + + direct.setState('connected') + expect(client.getPendingPath()).toBeNull() + + replacement.setState('connected') + await migrating + }) + + it('never downgrades a live session while a make-before-break replacement dials', async () => { + const oldSession = new FakeSession('connected') + const replacement = new FakeSession('connecting') + const client = createStableLogicalRpcClient(oldSession, 'relay') + const states: ConnectionState[] = [] + client.onStateChange((next) => states.push(next)) + + const migrating = client.migrateTo(replacement, 'relay') + replacement.setState('connecting') + replacement.setState('handshaking') + + expect(client.getState()).toBe('connected') + expect(client.getPendingPath()).toBeNull() + + replacement.setState('connected') + await migrating + + expect(states).toEqual(['connected']) + }) + + it('restores disconnected when a dial it was narrating fails', async () => { + const session = new FakeSession('connected') + const replacement = new FakeSession('connecting') + const client = createStableLogicalRpcClient(session, 'relay') + client.suspendActiveSession() + const states: ConnectionState[] = [] + client.onStateChange((next) => states.push(next)) + + const migrating = client.migrateTo(replacement, 'relay') + replacement.setState('handshaking') + replacement.setState('disconnected') + + await expect(migrating).rejects.toThrow(/disconnected/) + expect(states).toEqual(['handshaking', 'disconnected']) + expect(client.getState()).toBe('disconnected') + expect(client.getPendingPath()).toBeNull() + }) + it('closes a replacement that fails authentication and preserves the active session', async () => { const oldSession = new FakeSession('connected') const replacement = new FakeSession('connecting') diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 1fd19c9c01f..9f472c1cd84 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -1,5 +1,10 @@ import type { ConnectionState, RpcResponse } from './types' import type { RpcClient } from './rpc-client' +import { + forwardMigrationDialState, + type MigrationDialStateForwarder +} from './migration-dial-state-forwarder' +import { waitForAuthenticated } from './replacement-session-authentication' export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' @@ -31,9 +36,19 @@ type PendingRequest = { } export type StableLogicalRpcClient = RpcClient & { - migrateTo(session: RpcClient, path: MobileConnectionPath, timeoutMs?: number): Promise + migrateTo( + session: RpcClient, + path: MobileConnectionPath, + timeoutMs?: number, + // Checked after the replacement authenticates, before the swap — lets a racing + // caller withdraw when another path won while this dial was in flight. + shouldAbort?: () => boolean + ): Promise suspendActiveSession(): void getActivePath(): MobileConnectionPath + // Non-null only while a migration dial is publishing its own phases — the path the + // user is waiting on, which the still-bound active path can't name. + getPendingPath(): MobileConnectionPath | null getGeneration(): number } @@ -43,6 +58,7 @@ export function createStableLogicalRpcClient( ): StableLogicalRpcClient { let activeSession = initialSession let activePath = initialPath + let pendingPath: MobileConnectionPath | null = null let generation = 1 let closed = false let suspended = false @@ -138,9 +154,9 @@ export function createStableLogicalRpcClient( stateListeners.add(listener) return () => stateListeners.delete(listener) }, - notifyForeground: () => { + notifyForeground: (reason) => { if (!suspended) { - activeSession.notifyForeground() + activeSession.notifyForeground(reason) } }, close() { @@ -179,21 +195,45 @@ export function createStableLogicalRpcClient( publishState('disconnected') }, - async migrateTo(nextSession, path, timeoutMs = 12_000) { + async migrateTo(nextSession, path, timeoutMs = 12_000, shouldAbort) { if (closed) { nextSession.close() throw new Error('Client closed') } + // Why: naming the dial is independent of narrating it. The dominant relay case + // (direct dial fails) sits in 'reconnecting' — already amber, so forwarding adds + // nothing, but the user still has no idea relay is what's being tried. + if (suspended || state !== 'connected') { + pendingPath = path + } + const forwarder = forwardMigrationDialState({ + session: nextSession, + snapshot: () => ({ state, suspended }), + // Why: close() during the dial already published 'disconnected'; a late + // forwarded phase must not resurrect a closed client's dot. + publish: (next) => { + if (!closed) { + publishState(next) + } + } + }) try { await waitForAuthenticated(nextSession, timeoutMs) + if (closed) { + throw new Error('Client closed') + } + // Why: cutting over anyway would close a live winner and strand the user + // on the slower path (the happy-eyeballs race is first-authenticated-wins). + if (shouldAbort?.()) { + throw new Error('migration superseded') + } } catch (error) { + endDialForwarding(forwarder, true) nextSession.close() throw error } - if (closed) { - nextSession.close() - throw new Error('Client closed') - } + // Why: unbind before bindActiveState so the replacement has exactly one publisher. + endDialForwarding(forwarder, false) const previous = activeSession const previousStateUnsubscribe = activeStateUnsubscribe const nextGeneration = generation + 1 @@ -223,11 +263,24 @@ export function createStableLogicalRpcClient( }, getActivePath: () => activePath, + // Why: a previous session that recovers mid-dial makes the pending path a lie — + // once we're connected the user is no longer waiting on anything. + getPendingPath: () => (state === 'connected' ? null : pendingPath), getGeneration: () => generation } return logical + function endDialForwarding(forwarder: MigrationDialStateForwarder, failed: boolean): void { + forwarder.stop() + pendingPath = null + // Why: only walk back phases we published ourselves — a 'connected' here came from + // the still-live previous session and outranks the dead dial. + if (failed && forwarder.forwarded() && state !== 'connected') { + publishState('disconnected') + } + } + function attachSubscription( record: SubscriptionRecord, session: RpcClient, @@ -263,37 +316,3 @@ export function createStableLogicalRpcClient( } } } - -function waitForAuthenticated(session: RpcClient, timeoutMs: number): Promise { - if (session.getState() === 'connected') { - return Promise.resolve() - } - return new Promise((resolve, reject) => { - let settled = false - let timer: ReturnType | null = null - const unsubscribe = session.onStateChange((state) => { - if (state === 'connected') { - finish() - resolve() - } else if (state === 'auth-failed' || state === 'disconnected') { - finish() - reject(new Error(`replacement session ${state}`)) - } - }) - timer = setTimeout(() => { - finish() - reject(new Error('replacement session authentication timed out')) - }, timeoutMs) - - function finish(): void { - if (settled) { - return - } - settled = true - if (timer) { - clearTimeout(timer) - } - unsubscribe() - } - }) -} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index d031d11ff08..400deee90a6 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -59,6 +59,10 @@ export type ConnectionState = | 'reconnecting' | 'auth-failed' +// Why: a user-attention nudge must not tear down a healthy relay (probe it); only a +// network-change nudge marks the socket suspect enough to replace it. +export type ForegroundNudgeReason = 'focus' | 'app-resume' | 'network-change' + export type HostProfile = { id: string name: string diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts new file mode 100644 index 00000000000..a1e429b45b0 --- /dev/null +++ b/mobile/src/transport/use-all-host-clients.ts @@ -0,0 +1,56 @@ +import { useEffect, useMemo, useState } from 'react' +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath } from './stable-logical-rpc-client' +import type { ConnectionState } from './types' +import { useRpcClientContext } from './client-context' + +// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. +export function useAllHostClients(hostIds: string[]) { + const ctx = useRpcClientContext() + // Stable key so we don't tear down on every render of the array. + const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) + const [tick, setTick] = useState(0) + + useEffect(() => { + if (hostIds.length === 0) { + return + } + for (const id of hostIds) { + ctx.acquire(id) + } + const unsubs: (() => void)[] = [] + for (const id of hostIds) { + unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1))) + } + unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1))) + return () => { + for (const u of unsubs) { + u() + } + for (const id of hostIds) { + ctx.release(id) + } + } + }, [key]) + + return useMemo(() => { + const out: { + hostId: string + client: RpcClient + state: ConnectionState + path: MobileConnectionPath + }[] = [] + for (const id of hostIds) { + const all = ctx.getAllClients().find((entry) => entry.hostId === id) + if (all) { + out.push({ + hostId: id, + client: all.client, + state: ctx.getState(id), + path: ctx.getActivePath(id) + }) + } + } + return out + }, [key, tick]) +} diff --git a/mobile/src/transport/use-open-mobile-host-edit.ts b/mobile/src/transport/use-open-mobile-host-edit.ts index 68c20c5ae3b..a6517e3a3d9 100644 --- a/mobile/src/transport/use-open-mobile-host-edit.ts +++ b/mobile/src/transport/use-open-mobile-host-edit.ts @@ -1,29 +1,14 @@ -import { useCallback, useEffect, useRef } from 'react' -import { useNavigation, useRouter } from 'expo-router' -import { - navigateToMobileHostEdit, - type MobileHostEditNavigationController, - type MobileHostEditRootNavigation -} from './host-edit-navigation' +import { useCallback } from 'react' +import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' +import { mobileHostEditRouteTarget } from './host-edit-navigation' export function useOpenMobileHostEdit(): (hostId: string) => void { - const navigation = useNavigation() - const router = useRouter() - const pendingRef = useRef(null) - - useEffect( - () => () => { - pendingRef.current?.cancel() - pendingRef.current = null - }, - [] - ) + const openHostStackRoute = useOpenHostStackRoute() return useCallback( (hostId) => { - pendingRef.current?.cancel() - pendingRef.current = navigateToMobileHostEdit(navigation, router, hostId) + openHostStackRoute(hostId, mobileHostEditRouteTarget(hostId)) }, - [navigation, router] + [openHostStackRoute] ) } diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts index 7c6dafe6b14..72b9e572ba1 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -47,7 +47,7 @@ export function fetchHomeHostWorktreeInfo( } const result = response.result as { worktrees?: HomeWorktreeSummary[] } const worktrees = result.worktrees ?? [] - setCachedWorktrees(hostId, worktrees) + setCachedWorktrees(hostId, worktrees, { proven: true }) const active = worktrees.filter((w) => w.status && ACTIVE_STATUSES.has(w.status)) // Mirror the desktop's focused workspace (see pickResumeWorktree). const lastActive = pickResumeWorktree(worktrees) diff --git a/mobile/src/worktree/home-resume-card.test.ts b/mobile/src/worktree/home-resume-card.test.ts new file mode 100644 index 00000000000..949ef157785 --- /dev/null +++ b/mobile/src/worktree/home-resume-card.test.ts @@ -0,0 +1,175 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + isResumeTargetConfirmedMissing, + selectHomeResumeCard, + type HomeResumeCardInput +} from './home-resume-card' +import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info' + +const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8') + +function worktree(worktreeId: string): HomeWorktreeSummary { + return { + worktreeId, + repo: 'orca', + branch: 'main', + displayName: worktreeId, + liveTerminalCount: 0 + } +} + +function info(hostId: string, lastActive: HomeWorktreeSummary | null): HostWorktreeInfo { + return { hostId, totalWorktrees: 1, activeCount: 0, lastActiveWorktree: lastActive } +} + +function input(overrides: Partial = {}): HomeResumeCardInput { + return { + hosts: [{ id: 'host-1' }], + hostStates: {}, + worktreeInfo: { 'host-1': info('host-1', worktree('repo::/tmp/wt')) }, + lastVisited: null, + cachedWorktrees: () => null, + ...overrides + } +} + +describe('home resume card', () => { + it('reserves the slot from snapshot data while the host is still connecting', () => { + const connecting = selectHomeResumeCard(input({ hostStates: { 'host-1': 'connecting' } })) + + expect(connecting).toEqual({ + hostId: 'host-1', + worktree: worktree('repo::/tmp/wt'), + actionable: false + }) + }) + + it('keeps the same card in place once the host connects, only enabling it', () => { + const before = selectHomeResumeCard(input({ hostStates: { 'host-1': 'connecting' } })) + const after = selectHomeResumeCard(input({ hostStates: { 'host-1': 'connected' } })) + + // Same host and worktree before and after: the footer's Tasks card cannot shift down. + expect(after?.hostId).toBe(before?.hostId) + expect(after?.worktree.worktreeId).toBe(before?.worktree.worktreeId) + expect(after?.actionable).toBe(true) + }) + + it('leaves Tasks first when no host has resume history at all', () => { + expect( + selectHomeResumeCard(input({ worktreeInfo: { 'host-1': info('host-1', null) } })) + ).toBeNull() + }) + + it('prefers the worktree last opened on this device, enabled with its host', () => { + const visited = worktree('repo::/tmp/visited') + const fromLastVisited = (state: 'connecting' | 'connected') => + selectHomeResumeCard( + input({ + hostStates: { 'host-1': state }, + lastVisited: { hostId: 'host-1', worktreeId: visited.worktreeId }, + cachedWorktrees: (hostId) => (hostId === 'host-1' ? [visited] : null) + }) + ) + + expect(fromLastVisited('connecting')).toEqual({ + hostId: 'host-1', + worktree: visited, + actionable: false + }) + expect(fromLastVisited('connected')?.actionable).toBe(true) + }) + + it('prefers a connected host over an unconnected one holding older snapshot data', () => { + const card = selectHomeResumeCard( + input({ + hosts: [{ id: 'host-1' }, { id: 'host-2' }], + hostStates: { 'host-1': 'connecting', 'host-2': 'connected' }, + worktreeInfo: { + 'host-1': info('host-1', worktree('repo::/tmp/one')), + 'host-2': info('host-2', worktree('repo::/tmp/two')) + } + }) + ) + + expect(card).toEqual({ + hostId: 'host-2', + worktree: worktree('repo::/tmp/two'), + actionable: true + }) + }) + + it('gives a connected host precedence over an offline last-visited worktree', () => { + const visited = worktree('repo::/tmp/visited') + const card = selectHomeResumeCard( + input({ + hosts: [{ id: 'host-1' }, { id: 'host-2' }], + hostStates: { 'host-1': 'disconnected', 'host-2': 'connected' }, + worktreeInfo: { 'host-2': info('host-2', worktree('repo::/tmp/two')) }, + lastVisited: { hostId: 'host-1', worktreeId: visited.worktreeId }, + cachedWorktrees: (hostId) => (hostId === 'host-1' ? [visited] : null) + }) + ) + + // Reserving the slot must not cost the user a card they could actually open. + expect(card).toEqual({ + hostId: 'host-2', + worktree: worktree('repo::/tmp/two'), + actionable: true + }) + }) + + it('renders the home Resume card inert until its host connects', () => { + const start = homeSource.indexOf('{/* ─── Resume card ─── */}') + const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start) + + // Assert the markers first: a renamed banner would otherwise slice garbage and report a + // missing prop instead of the real cause. + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + + const resumeCard = homeSource.slice(start, end) + expect(resumeCard).toContain('disabled={!resumeCard.actionable}') + expect(resumeCard).toContain('!resumeCard.actionable && styles.cardDisabled') + }) +}) + +// Why (F7): the card is drawn from a snapshot that can name a workspace the desktop deleted +// while the phone was away, and tapping it lands on a session screen whose every RPC fails. +describe('isResumeTargetConfirmedMissing', () => { + const card = { + hostId: 'host-1', + worktree: worktree('repo::/tmp/wt'), + actionable: true + } as const + + it('confirms a target the host listed without', () => { + expect(isResumeTargetConfirmedMissing(card, [{ worktreeId: 'repo::/tmp/other' }])).toBe(true) + }) + + it('clears a target present in the listing', () => { + expect( + isResumeTargetConfirmedMissing(card, [ + { worktreeId: 'repo::/tmp/other' }, + { worktreeId: 'repo::/tmp/wt' } + ]) + ).toBe(false) + }) + + // An unproven catalog is silence, not evidence — the session screen bounces later instead. + it('never confirms without a proven catalog', () => { + expect(isResumeTargetConfirmedMissing(card, null)).toBe(false) + }) + + it('confirms the target when the host proves it has no workspaces at all', () => { + expect(isResumeTargetConfirmedMissing(card, [])).toBe(true) + }) + + it('exempts synthetic routes the catalog can never list', () => { + const folder = { ...card, worktree: worktree('folder:/Users/x/dir') } + const floating = { ...card, worktree: worktree('global-floating-terminal') } + + expect(isResumeTargetConfirmedMissing(folder, [])).toBe(false) + expect(isResumeTargetConfirmedMissing(floating, [])).toBe(false) + }) +}) diff --git a/mobile/src/worktree/home-resume-card.ts b/mobile/src/worktree/home-resume-card.ts new file mode 100644 index 00000000000..3a53090af49 --- /dev/null +++ b/mobile/src/worktree/home-resume-card.ts @@ -0,0 +1,89 @@ +import { isSyntheticWorkspaceRoute } from '../session/synthetic-workspace-route' +import type { ConnectionState } from '../transport/types' +import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info' + +// Picks what the home screen's Resume card shows, and whether tapping it can do anything. +// Why the two are separate: gating the card itself on 'connected' made it appear above Tasks +// seconds after first paint, sliding Tasks out from under the user's thumb. A candidate known +// from the persisted snapshot reserves the slot immediately and stays inert until a host connects. + +export type HomeResumeCard = Readonly<{ + hostId: string + worktree: HomeWorktreeSummary + actionable: boolean +}> + +export type HomeResumeCardInput = Readonly<{ + /** Home's sorted hosts — order decides which snapshot wins when several have history. */ + hosts: readonly { id: string }[] + hostStates: Readonly> + worktreeInfo: Readonly> + lastVisited: Readonly<{ hostId: string; worktreeId: string }> | null + cachedWorktrees: (hostId: string) => HomeWorktreeSummary[] | null +}> + +/** The worktree last opened on this device, so Resume reflects mobile session history. */ +function lastVisitedCard({ + lastVisited, + cachedWorktrees, + hostStates +}: HomeResumeCardInput): HomeResumeCard | null { + if (!lastVisited) { + return null + } + const match = cachedWorktrees(lastVisited.hostId)?.find( + (worktree) => worktree.worktreeId === lastVisited.worktreeId + ) + if (!match) { + return null + } + return { + hostId: lastVisited.hostId, + worktree: match, + actionable: hostStates[lastVisited.hostId] === 'connected' + } +} + +function hostHistoryCard( + { hosts, hostStates, worktreeInfo }: HomeResumeCardInput, + connectedOnly: boolean +): HomeResumeCard | null { + for (const host of hosts) { + const worktree = worktreeInfo[host.id]?.lastActiveWorktree + if (!worktree) { + continue + } + const actionable = hostStates[host.id] === 'connected' + if (actionable || !connectedOnly) { + return { hostId: host.id, worktree, actionable } + } + } + return null +} + +/** Whether tapping this card would open a workspace the host has already listed without. + * Deliberately one-directional: `provenWorktrees` is null whenever the catalog is a cold-start + * snapshot or still loading, and an unproven catalog is not evidence of a deletion — that case + * navigates as before and the session screen bounces once the host answers. */ +export function isResumeTargetConfirmedMissing( + card: HomeResumeCard, + provenWorktrees: readonly { worktreeId: string }[] | null +): boolean { + if (!provenWorktrees || isSyntheticWorkspaceRoute(card.worktree.worktreeId)) { + return false + } + return !provenWorktrees.some((worktree) => worktree.worktreeId === card.worktree.worktreeId) +} + +export function selectHomeResumeCard(input: HomeResumeCardInput): HomeResumeCard | null { + const visited = lastVisitedCard(input) + if (visited?.actionable) { + return visited + } + const connected = hostHistoryCard(input, true) + if (connected) { + return connected + } + // Nothing live to resume yet: hold the slot with whatever the snapshot remembers. + return visited ?? hostHistoryCard(input, false) +} diff --git a/mobile/src/worktree/host-workspace-list-state.ts b/mobile/src/worktree/host-workspace-list-state.ts index c14b5859ef4..6bd47ca28f9 100644 --- a/mobile/src/worktree/host-workspace-list-state.ts +++ b/mobile/src/worktree/host-workspace-list-state.ts @@ -14,9 +14,15 @@ export function selectHostWorkspaceListState( input: HostWorkspaceListStateInput ): 'loading' | 'catalog-error' | 'empty' | null { const { connState, worktreesLoaded, displayCount, sectionCount, catalogError } = input - const connecting = connState === 'connecting' || connState === 'reconnecting' + // Why: a blank disconnected list read as "no workspaces"; spin instead — the + // header verdict owns escalating a long outage. auth-failed keeps its own UI. + const pending = + connState === 'connecting' || + connState === 'handshaking' || + connState === 'reconnecting' || + connState === 'disconnected' if ( - (connecting && displayCount === 0) || + (pending && displayCount === 0) || (connState === 'connected' && !worktreesLoaded && displayCount === 0 && !catalogError) ) { return 'loading' diff --git a/mobile/src/worktree/last-visited-worktree-repo.test.ts b/mobile/src/worktree/last-visited-worktree-repo.test.ts index 4d484fbe715..eebbf87754d 100644 --- a/mobile/src/worktree/last-visited-worktree-repo.test.ts +++ b/mobile/src/worktree/last-visited-worktree-repo.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { readLastVisitedWorktreeRepoId } from './last-visited-worktree-repo' +import { + readLastVisitedWorktreeRecord, + readLastVisitedWorktreeRepoId +} from './last-visited-worktree-repo' describe('last visited worktree repo', () => { it('extracts the repo id for the current host', () => { @@ -19,3 +22,36 @@ describe('last visited worktree repo', () => { expect(readLastVisitedWorktreeRepoId(JSON.stringify({ hostId: 'host-1' }), 'host-1')).toBeNull() }) }) + +// Why (F7): home's Resume card navigates off this record, so anything it accepts becomes a route. +describe('readLastVisitedWorktreeRecord', () => { + it('reads a well-formed record', () => { + const raw = JSON.stringify({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' }) + + expect(readLastVisitedWorktreeRecord(raw)).toEqual({ + hostId: 'host-1', + worktreeId: 'repo-2::/tmp/worktree' + }) + }) + + it('reads absent, truncated, and wrong-shaped payloads as no history', () => { + expect(readLastVisitedWorktreeRecord(null)).toBeNull() + expect(readLastVisitedWorktreeRecord('')).toBeNull() + expect(readLastVisitedWorktreeRecord('{"hostId":"host-1"')).toBeNull() + expect(readLastVisitedWorktreeRecord('null')).toBeNull() + expect(readLastVisitedWorktreeRecord('"a string"')).toBeNull() + expect(readLastVisitedWorktreeRecord(JSON.stringify({ hostId: 'host-1' }))).toBeNull() + expect( + readLastVisitedWorktreeRecord(JSON.stringify({ hostId: 'host-1', worktreeId: 42 })) + ).toBeNull() + }) + + it('rejects empty ids that would build a route to nowhere', () => { + expect( + readLastVisitedWorktreeRecord(JSON.stringify({ hostId: '', worktreeId: 'repo::/wt' })) + ).toBeNull() + expect( + readLastVisitedWorktreeRecord(JSON.stringify({ hostId: 'host-1', worktreeId: '' })) + ).toBeNull() + }) +}) diff --git a/mobile/src/worktree/last-visited-worktree-repo.ts b/mobile/src/worktree/last-visited-worktree-repo.ts index 4ac9349c9fc..59e1a024451 100644 --- a/mobile/src/worktree/last-visited-worktree-repo.ts +++ b/mobile/src/worktree/last-visited-worktree-repo.ts @@ -2,7 +2,7 @@ import { getRepoIdFromMobileWorktreeId } from '../session/mobile-session-route-h export const LAST_VISITED_WORKTREE_STORAGE_KEY = 'orca:last-visited-worktree' -type LastVisitedWorktreeRecord = { +export type LastVisitedWorktreeRecord = { hostId: string worktreeId: string } @@ -11,7 +11,12 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function readLastVisitedWorktreeRecord(raw: string | null): LastVisitedWorktreeRecord | null { +/** Why exported: home drives its Resume card — and therefore a navigation — off this record, + * so a truncated or older-shaped payload must read as "no history" rather than reach the + * router as a half-built route. */ +export function readLastVisitedWorktreeRecord( + raw: string | null +): LastVisitedWorktreeRecord | null { if (!raw) { return null } @@ -20,7 +25,10 @@ function readLastVisitedWorktreeRecord(raw: string | null): LastVisitedWorktreeR if ( !isRecord(parsed) || typeof parsed.hostId !== 'string' || - typeof parsed.worktreeId !== 'string' + typeof parsed.worktreeId !== 'string' || + // An empty id builds a route to nowhere, so it is history we cannot act on. + parsed.hostId === '' || + parsed.worktreeId === '' ) { return null } diff --git a/mobile/src/worktree/worktree-show-resolution.test.ts b/mobile/src/worktree/worktree-show-resolution.test.ts new file mode 100644 index 00000000000..c2357b90dd9 --- /dev/null +++ b/mobile/src/worktree/worktree-show-resolution.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { classifyWorktreeShowResponse } from './worktree-show-resolution' +import type { RpcResponse } from '../transport/types' + +const meta = { runtimeId: 'runtime-1' } + +function failure(code: string, message: string): RpcResponse { + return { id: '1', ok: false, error: { code, message }, _meta: meta } +} + +describe('classifyWorktreeShowResponse', () => { + it('reads a successful show as present', () => { + expect( + classifyWorktreeShowResponse({ id: '1', ok: true, result: { worktree: {} }, _meta: meta }) + ).toBe('present') + }) + + it('reads the structured not-found code as missing', () => { + expect(classifyWorktreeShowResponse(failure('selector_not_found', 'Selector not found'))).toBe( + 'missing' + ) + }) + + it('reads an older desktop runtime_error carrying the bare token as missing', () => { + expect(classifyWorktreeShowResponse(failure('runtime_error', 'selector_not_found'))).toBe( + 'missing' + ) + }) + + it('reads a wrapped token after a message boundary as missing', () => { + expect( + classifyWorktreeShowResponse( + failure('runtime_error', "Error invoking remote method 'worktree.show': selector_not_found") + ) + ).toBe('missing') + }) + + // The bounce this feeds is destructive, so anything short of a definite answer must not trigger it. + it('leaves transient and ambiguous failures unknown', () => { + expect(classifyWorktreeShowResponse(failure('selector_ambiguous', 'Ambiguous'))).toBe('unknown') + expect(classifyWorktreeShowResponse(failure('method_not_found', 'Unknown method'))).toBe( + 'unknown' + ) + expect(classifyWorktreeShowResponse(failure('runtime_busy', 'Runtime busy'))).toBe('unknown') + }) + + it('does not read prose that merely mentions the token as missing', () => { + expect( + classifyWorktreeShowResponse( + failure('runtime_error', 'Access denied after a prior selector_not_found') + ) + ).toBe('unknown') + expect(classifyWorktreeShowResponse(failure('runtime_error', 'stale_selector_not_found'))).toBe( + 'unknown' + ) + }) +}) diff --git a/mobile/src/worktree/worktree-show-resolution.ts b/mobile/src/worktree/worktree-show-resolution.ts new file mode 100644 index 00000000000..aff9394d47c --- /dev/null +++ b/mobile/src/worktree/worktree-show-resolution.ts @@ -0,0 +1,38 @@ +import type { RpcResponse } from '../transport/types' + +// What a `worktree.show` answer proves about the target still existing on the host. +// 'unknown' is the safe verdict: every transient failure lands there, so a caller may +// only act destructively (bounce the route) on 'missing'. +export type WorktreeShowResolution = 'present' | 'missing' | 'unknown' + +const NOT_FOUND_CODE = 'selector_not_found' + +// Why: older desktops predate the passthrough allowlist and answer a missing selector as +// runtime_error carrying the token as its whole message, so the code alone under-detects. +// The token must end the message after a real boundary — prose that merely trails off in +// it ("…a prior selector_not_found diagnostic") is not a not-found answer. +const CODE_TOKEN_BOUNDARY = /(?:: |\n)[ \t]*$/ + +function endsWithNotFoundToken(message: string): boolean { + const trimmed = message.trimEnd() + if (!trimmed.endsWith(NOT_FOUND_CODE)) { + return false + } + const prefix = trimmed.slice(0, -NOT_FOUND_CODE.length) + return prefix.trim() === '' || CODE_TOKEN_BOUNDARY.test(prefix) +} + +export function classifyWorktreeShowResponse(response: RpcResponse): WorktreeShowResolution { + if (response.ok) { + return 'present' + } + if (response.error.code === NOT_FOUND_CODE) { + return 'missing' + } + // Why: the wrapped-token form only ever ships as runtime_error; any other code + // whose message trails off in the token proves nothing about the worktree. + if (response.error.code !== 'runtime_error') { + return 'unknown' + } + return endsWithNotFoundToken(response.error.message) ? 'missing' : 'unknown' +} diff --git a/package.json b/package.json index e8ec89daae0..d800838281b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.169-rc.0", + "version": "1.4.174-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -136,6 +136,7 @@ "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", "posthog-node": "^5.33.3", + "psl": "1.15.0", "qrcode": "^1.5.4", "react-i18next": "^17.0.8", "serve-sim": "^0.1.40", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4d53a979e1..c361a5382aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: posthog-node: specifier: ^5.33.3 version: 5.33.3 + psl: + specifier: 1.15.0 + version: 1.15.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -5793,9 +5796,16 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -12448,11 +12458,17 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 diff --git a/src/cli/browser-format.ts b/src/cli/browser-format.ts index 0c4466e9755..76fdc18db75 100644 --- a/src/cli/browser-format.ts +++ b/src/cli/browser-format.ts @@ -47,7 +47,8 @@ export function formatBrowserProfileList(result: BrowserProfileListResult): stri .map((profile) => { const marker = profile.scope === 'default' ? '* ' : ' ' const source = profile.source?.browserFamily ?? 'none' - return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}` + const userAgent = profile.userAgentMode === 'native' ? ' ua:native' : '' + return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}${userAgent}` }) .join('\n') } diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index f622f9d79b1..66c2aabb3fb 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -414,6 +414,29 @@ describe('orca cli browser tab profiles', () => { expect(logSpy).toHaveBeenCalledWith('No browser profiles found.') }) + it('marks native-UA profiles in text output', async () => { + queueFixtures( + callMock, + okFixture('req_profiles_native_ua', { + profiles: [ + { + id: 'google', + scope: 'isolated', + label: 'Google', + partition: 'persist:orca-browser-session-google', + source: null, + userAgentMode: 'native' + } + ] + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['tab', 'profile', 'list'], '/tmp/not-an-orca-worktree') + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('ua:native')) + }) + it('creates isolated browser tab profiles by default', async () => { queueFixtures( callMock, @@ -465,6 +488,33 @@ describe('orca cli browser tab profiles', () => { }) }) + it('creates a profile with the native user agent when requested', async () => { + queueFixtures( + callMock, + okFixture('req_profile_native_ua', { + profile: { + id: 'google', + scope: 'isolated', + label: 'Google', + partition: 'persist:orca-browser-session-google', + userAgentMode: 'native' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['tab', 'profile', 'create', '--label', 'Google', '--no-ua-spoof', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledWith('browser.profileCreate', { + label: 'Google', + scope: 'isolated', + userAgentMode: 'native' + }) + }) + it('rejects unknown --scope values instead of silently defaulting to isolated', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/cli/handlers/browser-profile.ts b/src/cli/handlers/browser-profile.ts index 4dc8ad731b3..769980f2d31 100644 --- a/src/cli/handlers/browser-profile.ts +++ b/src/cli/handlers/browser-profile.ts @@ -38,7 +38,8 @@ export const BROWSER_PROFILE_HANDLERS: Record = { const scope = parseScopeFlag(flags) const result = await client.call('browser.profileCreate', { label, - scope + scope, + ...(flags.get('no-ua-spoof') === true ? { userAgentMode: 'native' } : {}) }) if (result.result.profile === null) { // Why: registry refuses non-isolated/imported scopes; we already validated diff --git a/src/cli/help.ts b/src/cli/help.ts index 2804ea4aee9..e866a91ef22 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -321,6 +321,7 @@ Browser Options: --page Stable browser page id (preferred for concurrent workflows) --profile Browser profile id --show-profile Include the tab's browser profile in text output + --no-ua-spoof Keep Electron's native user agent for a new profile --format Screenshot image format --from Drag source element ref --to Drag target element ref @@ -616,6 +617,7 @@ export function formatFlagHelp(flag: string): string { page: '--page Stable browser page id from `orca tab list --json`', profile: '--profile Browser profile id', 'show-profile': '--show-profile Include tab profile in text output', + 'no-ua-spoof': "--no-ua-spoof Keep Electron's native user agent", format: '--format Screenshot image format' } diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 0d7d3a99c2d..391a22acf31 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -190,8 +190,9 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ { path: ['tab', 'profile', 'create'], summary: 'Create a browser session profile for browser tabs', - usage: 'orca tab profile create --label [--scope ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'label', 'scope'] + usage: + 'orca tab profile create --label [--scope ] [--no-ua-spoof] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'label', 'scope', 'no-ua-spoof'] }, { path: ['tab', 'profile', 'delete'], diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index e0688b44345..a6d5d61fd41 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -161,7 +161,14 @@ export function wrapWindowsGitBashHookCommand(scriptPath: string): string { : wrapWindowsHookCommand(scriptPath) } -export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string { +/** + * Extra form lines inserted before the final `payload@-` line (each should end with ` ^`). + * Used by Grok to attach `grokHome` without fragile string replace on the shared template. + */ +export function buildWindowsAgentHookPostCommand( + source: AgentHookSource, + extraFormLines: readonly string[] = [] +): string { // Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path. // Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads. return [ @@ -175,6 +182,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin ' --data-urlencode "worktreeId=%ORCA_WORKTREE_ID%" ^', ' --data-urlencode "env=%ORCA_AGENT_HOOK_ENV%" ^', ' --data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%" ^', + ...extraFormLines, ' --data-urlencode "payload@-" >nul 2>nul' ].join('\r\n') } diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts new file mode 100644 index 00000000000..31ca329bf8f --- /dev/null +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Cookie } from 'electron' +import { + isGoogleSourceBoundCookie, + normalizeCookieDomain, + replaceCookiesForImportedDomains +} from './browser-cookie-import-policy' + +function cookie(domain: string, name: string, path = '/', secure = true): Cookie { + return { + domain, + name, + path, + secure, + sameSite: 'unspecified', + value: 'secret' + } +} + +describe('isGoogleSourceBoundCookie', () => { + it('matches the allowlisted names only on google.com and its subdomains', () => { + expect(isGoogleSourceBoundCookie('SIDCC', '.google.com')).toBe(true) + expect(isGoogleSourceBoundCookie('AEC', 'accounts.google.com')).toBe(true) + expect(isGoogleSourceBoundCookie('__Secure-STRP', '.accounts.google.com')).toBe(true) + expect(isGoogleSourceBoundCookie('SIDCC', '.notgoogle.com')).toBe(false) + expect(isGoogleSourceBoundCookie('SIDCC', '.google.com.evil.example')).toBe(false) + expect(isGoogleSourceBoundCookie('SID', '.google.com')).toBe(false) + }) + + it('normalizes leading dots, case, and international domains consistently', () => { + expect(normalizeCookieDomain('..Accounts.Google.Com')).toBe('accounts.google.com') + expect(normalizeCookieDomain('münich.example')).toBe('xn--mnich-kva.example') + expect(normalizeCookieDomain('')).toBeNull() + }) + + it('rejects URL syntax that could normalize an invalid cookie scope to another domain', () => { + expect(normalizeCookieDomain('example.com/path')).toBeNull() + expect(normalizeCookieDomain('user@example.com')).toBeNull() + expect(normalizeCookieDomain('example.com:443')).toBeNull() + expect(normalizeCookieDomain('%65xample.com')).toBeNull() + expect(isGoogleSourceBoundCookie('SIDCC', 'user@google.com')).toBe(false) + }) +}) + +describe('replaceCookiesForImportedDomains', () => { + it('removes parent, exact, and child-domain cookies while preserving unrelated sites', async () => { + const existing = [ + cookie('.google.com', 'parent'), + { ...cookie('google.com', 'host-only-parent'), hostOnly: true }, + cookie('.accounts.google.com', 'exact', '/signin'), + cookie('.child.accounts.google.com', 'child', '/nested', false), + cookie('.google.com.evil.example', 'suffix-confusion'), + cookie('.example.com', 'unrelated') + ] + const get = vi.fn().mockResolvedValue(existing) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const removed = await replaceCookiesForImportedDomains({ get, remove, set }, [ + 'accounts.google.com' + ]) + + expect(removed).toHaveLength(3) + expect(get).toHaveBeenCalledWith({}) + expect(remove.mock.calls).toEqual([ + ['https://google.com/', 'parent'], + ['https://accounts.google.com/signin', 'exact'], + ['http://child.accounts.google.com/nested', 'child'] + ]) + expect(set).not.toHaveBeenCalled() + }) + + it('does not replace a private-suffix host cookie for a tenant import', async () => { + const get = vi + .fn() + .mockResolvedValue([ + { ...cookie('github.io', 'host-only-suffix'), hostOnly: true }, + cookie('.user.github.io', 'tenant') + ]) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['user.github.io']) + + expect(removed.map(({ name }) => name)).toEqual(['tenant']) + expect(remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant') + }) + + it('does not read or mutate the store when no valid domain scope exists', async () => { + const get = vi.fn() + const remove = vi.fn() + const set = vi.fn() + + await expect( + replaceCookiesForImportedDomains({ get, remove, set }, [ + '', + '...', + 'com', + 'co.uk', + 'github.io' + ]) + ).resolves.toEqual([]) + expect(get).not.toHaveBeenCalled() + expect(remove).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('keeps single-label intranet scopes from selecting descendant hosts', async () => { + const get = vi + .fn() + .mockResolvedValue([cookie('local', 'exact'), cookie('.service.local', 'descendant')]) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['local']) + + expect(removed.map(({ name }) => name)).toEqual(['exact']) + expect(remove).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledWith('https://local/', 'exact') + }) + + it('restores cookies removed before a later removal fails', async () => { + const existing = [ + cookie('.example.com', 'first', '/one'), + cookie('.example.com', 'second', '/two') + ] + const get = vi.fn().mockResolvedValue(existing) + const remove = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie store unavailable')) + const set = vi.fn().mockResolvedValue(undefined) + + await expect( + replaceCookiesForImportedDomains({ get, remove, set }, ['example.com']) + ).rejects.toThrow('cookie store unavailable') + expect(set).toHaveBeenCalledOnce() + expect(set).toHaveBeenCalledWith({ + url: 'https://example.com/one', + name: 'first', + value: 'secret', + domain: '.example.com', + path: '/one', + secure: true, + httpOnly: undefined, + sameSite: 'unspecified' + }) + }) +}) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts new file mode 100644 index 00000000000..216542f08ea --- /dev/null +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -0,0 +1,201 @@ +import type { Cookie, Cookies } from 'electron' +import { parse as parseDomain } from 'psl' + +const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([ + 'SIDCC', + '__Secure-1PSIDCC', + '__Secure-3PSIDCC', + '__Secure-STRP', + 'AEC' +]) + +export type CookieImportMode = 'merge' | 'replace-imported-domains' + +export function normalizeCookieDomain(domain: string): string | null { + const candidate = domain.trim().replace(/^\.+/, '') + const isBracketedIpv6 = candidate.startsWith('[') && candidate.endsWith(']') + if (!candidate || /[/\\@?#%]/.test(candidate) || (!isBracketedIpv6 && candidate.includes(':'))) { + return null + } + try { + const parsed = new URL(`https://${candidate}/`) + const normalized = parsed.hostname.toLowerCase() + if ( + parsed.username || + parsed.password || + parsed.port || + parsed.pathname !== '/' || + parsed.search || + parsed.hash || + normalized.endsWith('.') || + normalized.includes('..') + ) { + return null + } + return normalized + } catch { + return null + } +} + +export function normalizeCookieImportDomain(domain: string): string | null { + const normalized = normalizeCookieDomain(domain) + if (!normalized) { + return null + } + const parsed = parseDomain(normalized) + if ('error' in parsed) { + return normalized.startsWith('[') && normalized.endsWith(']') ? normalized : null + } + if (parsed.domain === null && parsed.listed) { + return null + } + return normalized +} + +export function isGoogleSourceBoundCookie(name: string, domain: string): boolean { + if (!GOOGLE_SOURCE_BOUND_COOKIE_NAMES.has(name)) { + return false + } + const normalized = normalizeCookieDomain(domain) + return normalized === 'google.com' || normalized?.endsWith('.google.com') === true +} + +function domainSuffixes(domain: string): string[] { + const labels = domain.split('.') + return labels.map((_, index) => labels.slice(index).join('.')) +} + +function importDomainAncestors(domain: string): string[] { + const parsed = parseDomain(domain) + const boundary = 'error' in parsed ? domain : (parsed.domain ?? domain) + const ancestors: string[] = [] + for (const suffix of domainSuffixes(domain)) { + ancestors.push(suffix) + if (suffix === boundary) { + break + } + } + return ancestors +} + +function importedDomainScopes(domains: readonly string[]): { + exact: Set + ancestors: Set + descendantRoots: Set +} { + const exact = new Set() + const ancestors = new Set() + const descendantRoots = new Set() + const seen = new Set() + for (const domain of domains) { + const candidate = normalizeCookieDomain(domain) + if (!candidate || seen.has(candidate)) { + continue + } + seen.add(candidate) + const normalized = normalizeCookieImportDomain(candidate) + if (!normalized || exact.has(normalized)) { + continue + } + exact.add(normalized) + if (normalized.includes('.')) { + descendantRoots.add(normalized) + } + for (const suffix of importDomainAncestors(normalized)) { + ancestors.add(suffix) + } + } + return { exact, ancestors, descendantRoots } +} + +function overlapsImportedDomain( + cookie: Cookie, + domain: string, + scopes: ReturnType +): boolean { + if (scopes.exact.has(domain)) { + return true + } + if (cookie.hostOnly !== true && scopes.ancestors.has(domain)) { + return true + } + return domainSuffixes(domain).some((suffix) => scopes.descendantRoots.has(suffix)) +} + +function cookieRemovalUrl(cookie: Cookie, domain: string): string | null { + try { + const url = new URL(`${cookie.secure ? 'https' : 'http'}://${domain}/`) + url.pathname = cookie.path?.startsWith('/') ? cookie.path : '/' + return url.toString() + } catch { + return null + } +} + +export async function restoreImportedDomainCookies( + store: Pick, + cookies: readonly Cookie[] +): Promise { + const failures: unknown[] = [] + for (const cookie of cookies) { + try { + const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + const url = domain ? cookieRemovalUrl(cookie, domain) : null + if (!url) { + continue + } + await store.set({ + url, + name: cookie.name, + value: cookie.value, + ...(cookie.hostOnly ? {} : { domain: cookie.domain }), + ...(cookie.path ? { path: cookie.path } : {}), + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + ...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {}) + }) + } catch (err) { + failures.push(err) + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Could not restore replaced cookies') + } +} + +export async function replaceCookiesForImportedDomains( + store: Pick, + importedDomains: readonly string[] +): Promise { + const scopes = importedDomainScopes(importedDomains) + if (scopes.exact.size === 0) { + return [] + } + + const existingCookies = await store.get({}) + const removedCookies: Cookie[] = [] + for (const cookie of existingCookies) { + const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + if (!domain || !overlapsImportedDomain(cookie, domain, scopes)) { + continue + } + const url = cookieRemovalUrl(cookie, domain) + if (!url) { + continue + } + try { + await store.remove(url, cookie.name) + removedCookies.push(cookie) + } catch (err) { + try { + await restoreImportedDomainCookies(store, removedCookies) + } catch (restoreError) { + throw new AggregateError([err, restoreError], 'Cookie replacement and rollback failed') + } + throw err + } + } + return removedCookies +} diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts new file mode 100644 index 00000000000..c3e330f5a67 --- /dev/null +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -0,0 +1,278 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + appGetPathMock, + clearPendingCookieImportMock, + execFileSyncMock, + sessionFromPartitionMock, + setPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + clearPendingCookieImportMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + setPendingCookieImportMock: vi.fn() +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock, + persistUserAgent: vi.fn() + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + dialog: { showOpenDialog: vi.fn() }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +describe('validated cookie replacement', () => { + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-replacement-test-')) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReset().mockReturnValue({ + cookies: { get: cookiesGetMock, remove: cookiesRemoveMock, set: cookiesSetMock } + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeCookies(cookies: unknown[]): string { + const filePath = join(tmpDir, 'cookies.json') + writeFileSync(filePath, JSON.stringify(cookies)) + return filePath + } + + it('filters Google source-bound cookies before replacing imported domain scopes', async () => { + cookiesGetMock.mockResolvedValue([ + cookie('.google.com', 'old-google'), + cookie('.accounts.google.com', 'old-accounts', '/signin'), + cookie('.unrelated.com', 'keep'), + cookie('.google.com.evil.example', 'keep-suffix-confusion') + ]) + const filePath = writeCookies([ + { domain: '.google.com', name: 'SIDCC', value: 'source-bound', secure: true }, + { domain: '.google.com', name: 'SAPISID', value: 'google-session', secure: true }, + { domain: '.example.com', name: 'SIDCC', value: 'not-google', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 3, + importedCookies: 2, + skippedCookies: 1, + domains: ['example.com', 'google.com'] + }) + expect(cookiesRemoveMock.mock.calls).toEqual([ + ['https://google.com/', 'old-google'], + ['https://accounts.google.com/signin', 'old-accounts'] + ]) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'SIDCC']) + expect(Math.max(...cookiesRemoveMock.mock.invocationCallOrder)).toBeLessThan( + Math.min(...cookiesSetMock.mock.invocationCallOrder) + ) + }) + + it('does not touch the store when every valid entry is source-bound', async () => { + const filePath = writeCookies([ + { domain: '.google.com', name: 'AEC', value: 'source-bound', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok && result.summary).toEqual({ + totalCookies: 1, + importedCookies: 0, + skippedCookies: 1, + domains: [] + }) + expect(cookiesGetMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('rejects URL-shaped domains before replacement can clear their normalized scope', async () => { + const filePath = writeCookies([ + { domain: 'example.com/path', name: 'session', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result).toEqual({ + ok: false, + reason: 'No valid cookies found. 1 entries were skipped due to missing or invalid fields.' + }) + expect(cookiesGetMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('restores the previous snapshot when an incoming cookie is rejected', async () => { + cookiesGetMock.mockResolvedValue([cookie('.example.com', 'existing')]) + cookiesSetMock + .mockResolvedValue(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie rejected')) + const filePath = writeCookies([ + { domain: '.example.com', name: 'first', value: 'new', secure: true }, + { domain: '.example.com', name: 'second', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok).toBe(false) + expect(cookiesRemoveMock.mock.calls).toEqual([ + ['https://example.com/', 'existing'], + ['https://example.com/', 'first'] + ]) + expect(cookiesSetMock).toHaveBeenLastCalledWith({ + url: 'https://example.com/', + name: 'existing', + value: 'old', + domain: '.example.com', + path: '/', + secure: true, + httpOnly: undefined, + sameSite: 'unspecified' + }) + }) + + it('fails closed when existing cookies cannot be replaced', async () => { + cookiesGetMock.mockRejectedValue(new Error('cookie store unavailable')) + const filePath = writeCookies([ + { domain: '.example.com', name: 'session', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok).toBe(false) + expect(cookiesSetMock).not.toHaveBeenCalled() + }) +}) + +describe('native Chromium integrity-cookie accounting', () => { + let clearStorageDataMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-accounting-test-')) + appGetPathMock.mockReset().mockReturnValue(join(tmpDir, 'userData')) + execFileSyncMock.mockReset().mockImplementation(() => { + throw new Error('OS browser version lookup unavailable') + }) + clearPendingCookieImportMock.mockClear() + setPendingCookieImportMock.mockClear() + clearStorageDataMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReset().mockReturnValue({ + cookies: { + flushStore: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + set: cookiesSetMock + }, + clearStorageData: clearStorageDataMock + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('includes domain-scoped integrity cookies in skippedCookies', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.google.com', name: 'AEC', value: 'source-bound' }, + { domain: '.google.com', name: 'SAPISID', value: 'google-session' }, + { domain: '.example.com', name: 'AEC', value: 'not-google' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 3, + importedCookies: 2, + skippedCookies: 1, + domains: ['example.com', 'google.com'] + }) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'AEC']) + } finally { + platformSpy.mockRestore() + } + }) + + it('preserves the destination when every source cookie is filtered or invalid', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.google.com', name: 'AEC', value: 'source-bound' }, + { domain: '.accounts.google.com', name: 'SIDCC', value: 'source-bound' }, + { domain: 'example.com/path', name: 'invalid-domain', value: 'invalid' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, [ + { domain: '.example.com', name: 'existing', value: 'keep' } + ]).close() + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok && result.summary).toEqual({ + totalCookies: 3, + importedCookies: 0, + skippedCookies: 3, + domains: [] + }) + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + expect(clearPendingCookieImportMock).not.toHaveBeenCalled() + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) +}) + +function cookie(domain: string, name: string, path = '/') { + return { domain, name, path, secure: true, sameSite: 'unspecified', value: 'old' } +} + +function chromeBrowser(cookiesPath: string) { + return { + family: 'chrome' as const, + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} diff --git a/src/main/browser/browser-cookie-import-test-database.ts b/src/main/browser/browser-cookie-import-test-database.ts index ead377e5b46..1f38f0adc36 100644 --- a/src/main/browser/browser-cookie-import-test-database.ts +++ b/src/main/browser/browser-cookie-import-test-database.ts @@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite' import { join } from 'node:path' type ChromiumCookieTestRow = { + domain?: string name: string value: string encryptedValue?: Buffer @@ -61,7 +62,7 @@ export function createChromiumCookieTestDatabase( rows.forEach((row, index) => { insert.run( 133_000_000_000_000 + index, - '.example.com', + row.domain ?? '.example.com', row.name, row.value, row.encryptedValue ?? Buffer.alloc(0), diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 657215a6fbc..d657b8ec56e 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -160,14 +160,22 @@ function buildExpiredSafariCookie(index: number): Buffer { describe('importCookiesFromFile', () => { let tmpDir: string + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType let cookiesSetMock: ReturnType beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-')) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) cookiesSetMock = vi.fn().mockResolvedValue(undefined) sessionFromPartitionMock.mockReset() sessionFromPartitionMock.mockReturnValue({ - cookies: { set: cookiesSetMock } + cookies: { + get: cookiesGetMock, + remove: cookiesRemoveMock, + set: cookiesSetMock + } }) }) @@ -362,7 +370,7 @@ describe('importCookiesFromFile', () => { expect(cookiesSetMock.mock.calls[2][0].url).toBe('http://nodot.com/') }) - it('counts cookies that fail to set', async () => { + it('rolls back replacement when a cookie fails to set', async () => { cookiesSetMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed')) const filePath = writeCookieFile([ @@ -371,12 +379,8 @@ describe('importCookiesFromFile', () => { ]) const result = await importCookiesFromFile(filePath, 'persist:test') - expect(result.ok).toBe(true) - if (!result.ok) { - return - } - expect(result.summary.importedCookies).toBe(1) - expect(result.summary.skippedCookies).toBe(1) + expect(result.ok).toBe(false) + expect(cookiesRemoveMock).toHaveBeenCalledWith('http://a.com/', 'ok') }) }) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 23bdd248777..131adcca66b 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ -import { app, type BrowserWindow, dialog, session } from 'electron' +import { app, type BrowserWindow, type Cookie, dialog, session } from 'electron' import { execFileSync } from 'node:child_process' import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' import { @@ -75,6 +75,14 @@ import type { } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' import { setupClientHintsOverride } from './browser-session-ua' +import { + isGoogleSourceBoundCookie, + normalizeCookieDomain, + normalizeCookieImportDomain, + replaceCookiesForImportedDomains, + restoreImportedDomainCookies, + type CookieImportMode +} from './browser-cookie-import-policy' import { createChromiumCookieSnapshot, type ChromiumCookieSnapshot @@ -500,13 +508,13 @@ function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'la // Why: cookies.set() needs a url to scope the cookie; derive it from domain + secure flag. function deriveUrl(domain: string, secure: boolean): string | null { - const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain - if (!cleanDomain || cleanDomain.includes(' ')) { + const normalizedDomain = normalizeCookieDomain(domain) + if (!normalizedDomain) { return null } const protocol = secure ? 'https' : 'http' try { - const url = new URL(`${protocol}://${cleanDomain}/`) + const url = new URL(`${protocol}://${normalizedDomain}/`) return url.toString() } catch { return null @@ -552,40 +560,79 @@ function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { async function importValidatedCookies( cookies: ValidatedCookie[], totalInput: number, - targetPartition: string + targetPartition: string, + mode: CookieImportMode ): Promise { + const importDomainCache = new Map() + const validDomainCookies = cookies.filter((cookie) => { + let valid = importDomainCache.get(cookie.domain) + if (valid === undefined) { + valid = normalizeCookieImportDomain(cookie.domain) !== null + importDomainCache.set(cookie.domain, valid) + } + return valid + }) + const importableCookies = validDomainCookies.filter( + (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) + ) + const integritySkipped = validDomainCookies.length - importableCookies.length + const invalidDomainSkipped = cookies.length - validDomainCookies.length diag( - `importValidatedCookies: ${cookies.length} validated of ${totalInput} total, partition="${targetPartition}"` + `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped of ${totalInput} total, partition="${targetPartition}"` ) const targetSession = session.fromPartition(targetPartition) let importedCount = 0 - let skipped = totalInput - cookies.length + let skipped = totalInput - importableCookies.length const domainSet = new Set() + let replacedCookies: Cookie[] | null = null + + if (mode === 'replace-imported-domains' && importableCookies.length > 0) { + try { + replacedCookies = await replaceCookiesForImportedDomains( + targetSession.cookies, + importableCookies.map((cookie) => cookie.domain) + ) + diag(` removed ${replacedCookies.length} existing cookies in imported domain scopes`) + } catch (err) { + diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + return { + ok: false, + reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + } + } + } // Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net. const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + const importedCookieKeys: { url: string; name: string }[] = [] + let setFailure: unknown = null - for (const cookie of cookies) { + for (const cookie of importableCookies) { try { // Why: Chromium rejects __Host- cookies unless they omit domain and use path=/. const isHostPrefixed = cookie.name.startsWith('__Host-') + const path = isHostPrefixed ? '/' : cookie.path await targetSession.cookies.set({ url: cookie.url, name: cookie.name, value: stripNonPrintable(cookie.value), ...(isHostPrefixed ? {} : { domain: cookie.domain }), - path: isHostPrefixed ? '/' : cookie.path, + path, secure: cookie.secure, httpOnly: cookie.httpOnly, sameSite: cookie.sameSite, expirationDate: cookie.expirationDate }) + const removalUrl = new URL(cookie.url) + removalUrl.pathname = path.startsWith('/') ? path : '/' + importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name }) importedCount++ // Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data. const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain domainSet.add(cleanDomain) } catch (err) { skipped++ + setFailure = err if (skipped <= 5) { // Find the exact offending character position and code const val = cookie.value @@ -601,6 +648,32 @@ async function importValidatedCookies( ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}` ) } + if (replacedCookies) { + break + } + } + } + + if (setFailure && replacedCookies) { + const rollbackFailures: unknown[] = [] + for (const cookie of importedCookieKeys.toReversed()) { + try { + await targetSession.cookies.remove(cookie.url, cookie.name) + } catch (err) { + rollbackFailures.push(err) + } + } + try { + await restoreImportedDomainCookies(targetSession.cookies, replacedCookies) + } catch (err) { + rollbackFailures.push(err) + } + if (rollbackFailures.length > 0) { + diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) + } + return { + ok: false, + reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') } } @@ -690,7 +763,12 @@ export async function importCookiesFromFile( } } - return importValidatedCookies(validated, parsed.length, targetPartition) + return importValidatedCookies( + validated, + parsed.length, + targetPartition, + 'replace-imported-domains' + ) } // --------------------------------------------------------------------------- @@ -1338,7 +1416,12 @@ async function importCookiesFromFirefox( return { ok: false, reason: 'No valid cookies found in Firefox.' } } - return importValidatedCookies(validated, rows.length, targetPartition) + return importValidatedCookies( + validated, + rows.length, + targetPartition, + 'replace-imported-domains' + ) } catch (err) { rmSync(tmpDir, { recursive: true, force: true }) diag(` Firefox import failed: ${String(err)}`) @@ -1392,7 +1475,12 @@ async function importCookiesFromSafari( return { ok: false, reason: 'All Safari cookies are expired.' } } - return importValidatedCookies(valid, cookies.length, targetPartition) + return importValidatedCookies( + valid, + cookies.length, + targetPartition, + 'replace-imported-domains' + ) } catch (err) { diag(` Safari import failed: ${String(err)}`) return { ok: false, reason: 'Could not import cookies from Safari.' } @@ -1572,22 +1660,6 @@ export async function importCookiesFromBrowser( } } - // Why: Google integrity cookies are bound to the source browser's TLS/env; importing them triggers CookieMismatch, so skip and let Google reissue. - const INTEGRITY_COOKIE_NAMES = new Set([ - 'SIDCC', - '__Secure-1PSIDCC', - '__Secure-3PSIDCC', - '__Secure-STRP', - 'AEC' - ]) - function isIntegrityCookie(name: string, domain: string): boolean { - if (!INTEGRITY_COOKIE_NAMES.has(name)) { - return false - } - const d = domain.startsWith('.') ? domain.slice(1) : domain - return d === 'google.com' || d.endsWith('.google.com') - } - let imported = 0 let skipped = 0 let integritySkipped = 0 @@ -1608,6 +1680,7 @@ export async function importCookiesFromBrowser( } const decryptedCookies: DecryptedCookie[] = [] + const sourceDomainValidity = new Map() // Why: staging only backs the cold-restart replay, so any failure writing it disables that // fallback instead of aborting an import whose in-memory half still works. @@ -1658,11 +1731,21 @@ export async function importCookiesFromBrowser( const domain = sourceRow.host_key as string const name = sourceRow.name as string - if (isIntegrityCookie(name, domain)) { + if (isGoogleSourceBoundCookie(name, domain)) { integritySkipped++ continue } + let validDomain = sourceDomainValidity.get(domain) + if (validDomain === undefined) { + validDomain = normalizeCookieImportDomain(domain) !== null + sourceDomainValidity.set(domain, validDomain) + } + if (!validDomain) { + skipped++ + continue + } + const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain domainSet.add(cleanDomain) @@ -1704,6 +1787,21 @@ export async function importCookiesFromBrowser( } diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) + if (decryptedCookies.length === 0) { + closeStagingDb() + discardStagingFile() + return { + ok: true, + profileId: '', + summary: { + totalCookies: sourceRows.length, + importedCookies: 0, + skippedCookies: skipped + integritySkipped, + domains: [] + } + } + } + if (stagingDb) { try { stagingDb.exec('COMMIT') @@ -1786,7 +1884,7 @@ export async function importCookiesFromBrowser( const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, importedCookies: imported, - skippedCookies: skipped, + skippedCookies: skipped + integritySkipped, domains: [...domainSet].sort(), ...(warning ? { warning } : {}) } diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index b93a0358537..fcda3846045 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -2891,6 +2891,36 @@ describe('browserManager', () => { }) }) + it.each([false, true])( + 'keeps the session UA for native-mode profiles when mobile=%s', + async (mobile) => { + const { guest, debuggerSendCommand } = makeGuest(mobile ? 4244 : 4243) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: `tab-native-${mobile}`, + sessionProfileId: 'native-profile', + userAgentMode: 'native', + webContentsId: guest.id as number, + rendererWebContentsId + }) + + await expect( + browserManager.setViewportOverride(`tab-native-${mobile}`, { + width: mobile ? 375 : 1024, + height: mobile ? 667 : 768, + deviceScaleFactor: mobile ? 2 : 1, + mobile + }) + ).resolves.toBe(true) + + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + } + ) + it('clears device metrics and disables touch for override=null', async () => { const { guest, debuggerSendCommand } = makeGuest(4343) webContentsFromIdMock.mockReturnValue(guest) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 66f6ac653bf..d4547abada8 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -46,7 +46,8 @@ import { cleanElectronUserAgent } from './browser-session-ua' import type { BrowserViewportOverride, BrowserCertificateFailure, - BrowserLoadError + BrowserLoadError, + BrowserSessionUserAgentMode } from '../../shared/types' import { type BrowserAnnotationViewportBridgeOptions, @@ -145,6 +146,7 @@ export type BrowserGuestRegistration = { workspaceId?: string worktreeId?: string sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode webContentsId: number rendererWebContentsId: number } @@ -225,6 +227,7 @@ export class BrowserManager { // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. private readonly workspaceIdByPageId = new Map() private readonly sessionProfileIdByPageId = new Map() + private readonly userAgentModeByPageId = new Map() private readonly rendererWebContentsIdByTabId = new Map() // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. private readonly viewportOpsByTabId = new Map>() @@ -980,6 +983,7 @@ export class BrowserManager { workspaceId, worktreeId, sessionProfileId, + userAgentMode, webContentsId, rendererWebContentsId }: BrowserGuestRegistration): boolean { @@ -1020,6 +1024,11 @@ export class BrowserManager { this.workspaceIdByPageId.set(browserTabId, workspaceId) } this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserTabId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserTabId) + } this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) if (worktreeId) { this.worktreeIdByTabId.set(browserTabId, worktreeId) @@ -1081,6 +1090,7 @@ export class BrowserManager { this.rendererWebContentsIdByTabId.delete(browserTabId) this.workspaceIdByPageId.delete(browserTabId) this.sessionProfileIdByPageId.delete(browserTabId) + this.userAgentModeByPageId.delete(browserTabId) this.worktreeIdByTabId.delete(browserTabId) // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. this.viewportOpsByTabId.delete(browserTabId) @@ -1092,11 +1102,13 @@ export class BrowserManager { browserPageId, worktreeId, sessionProfileId, + userAgentMode, webContentsId }: { browserPageId: string worktreeId?: string sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode webContentsId: number }): void { const guest = webContents.fromId(webContentsId) @@ -1113,6 +1125,11 @@ export class BrowserManager { this.webContentsIdByTabId.set(browserPageId, webContentsId) this.tabIdByWebContentsId.set(webContentsId, browserPageId) this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserPageId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserPageId) + } if (worktreeId) { this.worktreeIdByTabId.set(browserPageId, worktreeId) } @@ -1141,6 +1158,7 @@ export class BrowserManager { this.popupOwnerContextByGuestId.clear() this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() + this.userAgentModeByPageId.clear() this.pendingLoadFailuresByGuestId.clear() this.loadErrorsByGuestId.clear() this.clearedLoadErrorsByGuestId.clear() @@ -1532,35 +1550,38 @@ export class BrowserManager { enabled: override.mobile, maxTouchPoints: override.mobile ? 5 : 0 }) - if (override.mobile) { - const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent())) - // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. - await dbg.sendCommand('Emulation.setUserAgentOverride', { - userAgent: buildMobileUserAgent(chromeMajor), - userAgentMetadata: { - brands: [ - { brand: 'Google Chrome', version: chromeMajor }, - { brand: 'Chromium', version: chromeMajor }, - { brand: 'Not/A)Brand', version: '24' } - ], - fullVersionList: [ - { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, - { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, - { brand: 'Not/A)Brand', version: '24.0.0.0' } - ], - fullVersion: `${chromeMajor}.0.0.0`, - platform: 'iOS', - platformVersion: '17.0', - architecture: '', - model: 'iPhone', - mobile: true - } - }) - } else { - // Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session. - await dbg.sendCommand('Emulation.setUserAgentOverride', { - userAgent: cleanElectronUserAgent(guest.getUserAgent()) - }) + // Why: viewport sizing must not override a profile's explicit native-UA identity. + if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { + if (override.mobile) { + const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent())) + // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. + await dbg.sendCommand('Emulation.setUserAgentOverride', { + userAgent: buildMobileUserAgent(chromeMajor), + userAgentMetadata: { + brands: [ + { brand: 'Google Chrome', version: chromeMajor }, + { brand: 'Chromium', version: chromeMajor }, + { brand: 'Not/A)Brand', version: '24' } + ], + fullVersionList: [ + { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, + { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, + { brand: 'Not/A)Brand', version: '24.0.0.0' } + ], + fullVersion: `${chromeMajor}.0.0.0`, + platform: 'iOS', + platformVersion: '17.0', + architecture: '', + model: 'iPhone', + mobile: true + } + }) + } else { + // Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session. + await dbg.sendCommand('Emulation.setUserAgentOverride', { + userAgent: cleanElectronUserAgent(guest.getUserAgent()) + }) + } } } else { await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) diff --git a/src/main/browser/browser-screencast-snapshot-scaling.test.ts b/src/main/browser/browser-screencast-snapshot-scaling.test.ts new file mode 100644 index 00000000000..4358d618376 --- /dev/null +++ b/src/main/browser/browser-screencast-snapshot-scaling.test.ts @@ -0,0 +1,94 @@ +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' + +import { startBrowserScreencast } from './browser-screencast-stream' + +function createMockWebContents(capturePage: () => Promise) { + let attached = false + const dbg = new EventEmitter() as EventEmitter & { + isAttached: ReturnType + attach: ReturnType + detach: ReturnType + sendCommand: ReturnType + } + dbg.isAttached = vi.fn(() => attached) + dbg.attach = vi.fn(() => { + attached = true + }) + dbg.detach = vi.fn(() => { + attached = false + }) + dbg.sendCommand = vi.fn(async () => ({})) + return { isDestroyed: vi.fn(() => false), debugger: dbg, capturePage: vi.fn(capturePage) } +} + +function createCapturedImage(width: number, height: number) { + const resized = { + getSize: vi.fn(() => ({ width, height })), + resize: vi.fn(), + toJPEG: vi.fn(() => Buffer.from('scaled-frame')), + toPNG: vi.fn(() => Buffer.from('scaled-frame')) + } + const image = { + getSize: vi.fn(() => ({ width, height })), + resize: vi.fn(() => resized), + toJPEG: vi.fn(() => Buffer.from('captured-frame')), + toPNG: vi.fn(() => Buffer.from('captured-frame')) + } + return { image, resized } +} + +describe('browser screencast snapshot scaling', () => { + it('scales a hi-DPI capture down to the requested frame bounds', async () => { + const { image, resized } = createCapturedImage(4000, 3000) + const webContents = createMockWebContents(async () => image) + const onFrame = vi.fn() + + const session = await startBrowserScreencast(webContents as never, { + format: 'jpeg', + quality: 70, + maxWidth: 1440, + maxHeight: 1200, + viewportWidth: 2000, + viewportHeight: 1500, + deviceScaleFactor: 2, + everyNthFrame: 2, + minFrameIntervalMs: 0, + onFrame + }) + + await vi.waitFor(() => expect(onFrame).toHaveBeenCalledTimes(1)) + expect(image.resize).toHaveBeenCalledWith({ width: 1440, height: 1080 }) + expect(resized.toJPEG).toHaveBeenCalledWith(70) + expect(image.toJPEG).not.toHaveBeenCalled() + + session.stop() + await session.done + }) + + it('leaves a capture already within the frame bounds unscaled', async () => { + const { image } = createCapturedImage(1200, 800) + const webContents = createMockWebContents(async () => image) + const onFrame = vi.fn() + + const session = await startBrowserScreencast(webContents as never, { + format: 'jpeg', + quality: 70, + maxWidth: 1440, + maxHeight: 1200, + viewportWidth: 1200, + viewportHeight: 800, + everyNthFrame: 2, + minFrameIntervalMs: 0, + onFrame + }) + + await vi.waitFor(() => expect(onFrame).toHaveBeenCalledTimes(1)) + expect(image.resize).not.toHaveBeenCalled() + expect(image.toJPEG).toHaveBeenCalledWith(70) + + session.stop() + await session.done + }) +}) diff --git a/src/main/browser/browser-screencast-stream.ts b/src/main/browser/browser-screencast-stream.ts index ad2e1e27e62..15a5395cef8 100644 --- a/src/main/browser/browser-screencast-stream.ts +++ b/src/main/browser/browser-screencast-stream.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: screencast setup, CDP lifecycle, metadata normalization, and stream teardown stay together so frame behavior cannot drift across files. */ import { Buffer } from 'node:buffer' -import type { WebContents } from 'electron' +import type { NativeImage, WebContents } from 'electron' import { BrowserScreencastOpcode, encodeBrowserScreencastFrame, @@ -86,6 +86,20 @@ function scaleToFit( } } +// Why: capturePage returns device pixels, so a hi-DPI viewport yields a bitmap several times +// larger than the live path is allowed to send. Apply the caller's cap here too. +function scaleSnapshotToFit(image: NativeImage, options: BrowserScreencastOptions): NativeImage { + const size = image.getSize() + if (!size.width || !size.height) { + return image + } + const fitted = scaleToFit(size.width, size.height, options.maxWidth, options.maxHeight) + if (fitted.width === size.width && fitted.height === size.height) { + return image + } + return image.resize(fitted) +} + function isNearSize( actual: { width: number; height: number }, expected: { width: number; height: number } @@ -518,8 +532,9 @@ export async function startBrowserScreencast( width: viewportWidth, height: viewportHeight }) + const capture = scaleSnapshotToFit(nativeImage, options) const buffer = - options.format === 'png' ? nativeImage.toPNG() : nativeImage.toJPEG(options.quality) + options.format === 'png' ? capture.toPNG() : capture.toJPEG(options.quality) if (buffer.byteLength > 0) { image = new Uint8Array(buffer) } diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 50166f6f52e..76ddaf4fee8 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -195,7 +195,9 @@ describe('BrowserSessionRegistry persistence', () => { orcaProfileId: 'local-work', profileDirectory: '/user-data/profiles/local-work' }) - const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser') + const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser', { + userAgentMode: 'native' + }) expect(profile).not.toBeNull() expect(fsState.files.has(profileMetaPath)).toBe(true) @@ -203,10 +205,35 @@ describe('BrowserSessionRegistry persistence', () => { expect(JSON.parse(fsState.files.get(profileMetaPath) ?? '{}').profiles[0]).toMatchObject({ id: profile!.id, partition: profile!.partition, - label: 'Work Browser' + label: 'Work Browser', + userAgentMode: 'native' }) }) + it('keeps UA cleaning as the fallback for profiles without an override', async () => { + const fsState = createFsState() + const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.createProfile('isolated', 'Default identity') + + const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value + expect(profileSession.setUserAgent).toHaveBeenCalledWith('Mozilla/5.0 Orca') + expect(setupClientHintsOverrideMock).toHaveBeenCalledWith(profileSession, 'Mozilla/5.0 Orca') + }) + + it('leaves UA and client hints untouched for native-mode profiles', async () => { + const fsState = createFsState() + const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' }) + + const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value + expect(profileSession.setUserAgent).not.toHaveBeenCalled() + expect(setupClientHintsOverrideMock).not.toHaveBeenCalled() + }) + it('merges partition-keyed pending entries without clobbering unrelated entries', async () => { const fsState = createFsState() seedMeta(fsState, { @@ -320,7 +347,7 @@ describe('BrowserSessionRegistry persistence', () => { expect(fsState.present.has('/staged/default')).toBe(true) }) - it('restores persisted UA for non-default partitions', async () => { + it('restores a persisted source UA even for native-mode profiles', async () => { const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' const importedUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36' const defaultUa = 'Mozilla/5.0 Chrome/119.0.0.0 Safari/537.36' @@ -340,7 +367,8 @@ describe('BrowserSessionRegistry persistence', () => { scope: 'imported', partition: importedPartition, label: 'Imported', - source: { browserFamily: 'comet', importedAt: 1 } + source: { browserFamily: 'comet', importedAt: 1 }, + userAgentMode: 'native' } ] }) @@ -368,6 +396,44 @@ describe('BrowserSessionRegistry persistence', () => { ).toBe(true) }) + it('preserves native mode across hydration when no source UA was imported', async () => { + const importedPartition = 'persist:orca-browser-session-12121212-1212-4121-8121-121212121212' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: null, + pendingCookieImports: {}, + profiles: [ + { + id: '12121212-1212-4121-8121-121212121212', + scope: 'isolated', + partition: importedPartition, + label: 'Google', + source: null, + userAgentMode: 'native' + } + ] + }) + + const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.initializeBrowserSessionsFromPersistedState() + + const importedSessions = sessionFromPartitionMock.mock.results + .filter((_, index) => sessionFromPartitionMock.mock.calls[index]?.[0] === importedPartition) + .map((result) => result.value) + expect(importedSessions.length).toBeGreaterThan(0) + expect(importedSessions.every((sess) => sess.setUserAgent.mock.calls.length === 0)).toBe(true) + expect( + setupClientHintsOverrideMock.mock.calls.some( + ([sess]) => (sess as { partition?: string }).partition === importedPartition + ) + ).toBe(false) + }) + it('sets up default-partition policies on restore', async () => { const fsState = createFsState() seedMeta(fsState, { diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index f116e08c0da..31260f4cf4c 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -85,6 +85,13 @@ describe('BrowserSessionRegistry', () => { expect(profile).toBeNull() }) + it('rejects invalid user-agent modes at the registry boundary', () => { + const profile = browserSessionRegistry.createProfile('isolated', 'Invalid UA', { + userAgentMode: 'rotating' as never + }) + expect(profile).toBeNull() + }) + it('allows created profile partitions', () => { const profile = browserSessionRegistry.createProfile('isolated', 'Allowed') expect(profile).not.toBeNull() @@ -199,6 +206,25 @@ describe('BrowserSessionRegistry', () => { expect(browserSessionRegistry.isAllowedPartition(fakeProfile.partition)).toBe(true) }) + it('rejects a persisted profile whose partition belongs to a different profile id', () => { + const profileId = '00000000-0000-4000-8000-000000000021' + const claimedPartition = 'persist:orca-browser-session-00000000-0000-4000-8000-000000000022' + + browserSessionRegistry.hydrateFromPersisted([ + { + id: profileId, + scope: 'isolated', + partition: claimedPartition, + label: 'Conflicting identity', + source: null, + userAgentMode: 'native' + } + ]) + + expect(browserSessionRegistry.getProfile(profileId)).toBeNull() + expect(browserSessionRegistry.isAllowedPartition(claimedPartition)).toBe(false) + }) + it('sets up session policies for new partitions', () => { browserSessionRegistry.createProfile('isolated', 'Policy Test') expect(sessionFromPartitionMock).toHaveBeenCalled() diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 1dfd03c778e..e30c3a0230d 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -16,10 +16,13 @@ import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { DEFAULT_LOCAL_ORCA_PROFILE_ID, getOrcaProfileBrowserDefaultPartition, - getOrcaProfileBrowserPartitionSegment, getOrcaProfileBrowserSessionPartition } from '../../shared/orca-profiles' -import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types' +import type { + BrowserSessionProfile, + BrowserSessionProfileCreateOptions, + BrowserSessionProfileScope +} from '../../shared/types' import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' @@ -46,8 +49,8 @@ export type BrowserSessionRegistryProfileOptions = { } const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' -const LEGACY_BROWSER_SESSION_PARTITION_RE = - /^persist:orca-browser-session-[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/ +const BROWSER_SESSION_PROFILE_ID_RE = + /^[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/ // Why: source of truth for valid partitions; will-attach-webview consults it so a compromised renderer can't smuggle in an arbitrary partition. @@ -181,13 +184,10 @@ class BrowserSessionRegistry { } // Why: nothing else installs policies on the default partition (hydrate skips it), so without this its guest permissions would be denied. - this.setupSessionPolicies(this.defaultPartition) + this.setupSessionPolicies(this.getDefaultProfile()) - const partitions = new Set([ - this.defaultPartition, - ...this.listProfiles().map((p) => p.partition) - ]) - for (const partition of partitions) { + for (const profile of this.listProfiles()) { + const partition = profile.partition try { const sess = session.fromPartition(partition) const persistedUa = meta.userAgentByPartition[partition] @@ -197,6 +197,10 @@ class BrowserSessionRegistry { continue } + if (profile.userAgentMode === 'native') { + continue + } + // Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile. const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) sess.setUserAgent(cleanUA) @@ -360,9 +364,18 @@ class BrowserSessionRegistry { return this.profiles.get(profileId)?.partition ?? null } - createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null { - // Why: block scope:'default' here — only the constructor makes the default profile; a second one sharing the partition breaks delete. - if (scope === 'default') { + createProfile( + scope: BrowserSessionProfileScope, + label: string, + options: BrowserSessionProfileCreateOptions = {} + ): BrowserSessionProfile | null { + // Why: the registry is also an IPC boundary, so runtime types alone cannot keep invalid values out of persisted metadata. + if ( + (scope !== 'isolated' && scope !== 'imported') || + (options.userAgentMode !== undefined && + options.userAgentMode !== 'clean' && + options.userAgentMode !== 'native') + ) { return null } const id = randomUUID() @@ -373,10 +386,11 @@ class BrowserSessionRegistry { scope, partition, label, - source: null + source: null, + ...(options.userAgentMode ? { userAgentMode: options.userAgentMode } : {}) } this.profiles.set(id, profile) - this.setupSessionPolicies(partition) + this.setupSessionPolicies(profile) this.persistProfiles() return profile } @@ -471,25 +485,18 @@ class BrowserSessionRegistry { typeof candidate.id === 'string' && typeof candidate.partition === 'string' && typeof candidate.label === 'string' && - this.isProfileOwnedSessionPartition(candidate.partition) + (candidate.userAgentMode === undefined || + candidate.userAgentMode === 'clean' || + candidate.userAgentMode === 'native') && + this.isProfileOwnedSessionPartition(candidate.id, candidate.partition) ) } - private isProfileOwnedSessionPartition(partition: string): boolean { - if ( - this.activeOrcaProfileId === DEFAULT_LOCAL_ORCA_PROFILE_ID && - LEGACY_BROWSER_SESSION_PARTITION_RE.test(partition) - ) { - return true - } - - const segment = getOrcaProfileBrowserPartitionSegment(this.activeOrcaProfileId) - const prefix = `persist:orca-profile-${segment}-browser-session-` - if (!partition.startsWith(prefix)) { - return false - } - const profileId = partition.slice(prefix.length) - return /^[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/.test(profileId) + private isProfileOwnedSessionPartition(profileId: string, partition: string): boolean { + return ( + BROWSER_SESSION_PROFILE_ID_RE.test(profileId) && + partition === getOrcaProfileBrowserSessionPartition(this.activeOrcaProfileId, profileId) + ) } hydrateFromPersisted(profiles: BrowserSessionProfile[]): void { @@ -499,7 +506,7 @@ class BrowserSessionRegistry { } this.profiles.set(profile.id, profile) if (profile.partition !== this.defaultPartition) { - this.setupSessionPolicies(profile.partition) + this.setupSessionPolicies(profile) } } } @@ -514,14 +521,15 @@ class BrowserSessionRegistry { browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } - private setupSessionPolicies(partition: string): void { + private setupSessionPolicies(profile: BrowserSessionProfile): void { + const { partition } = profile if (this.configuredPartitions.has(partition)) { return } const sess = session.fromPartition(partition) browserManager.installCertificateRequestGuard(sess) - if (typeof sess.getUserAgent === 'function') { + if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') { const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) sess.setUserAgent(cleanUA) setupClientHintsOverride(sess, cleanUA) diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts index c76eb24b657..5eb6e9b8435 100644 --- a/src/main/browser/offscreen-browser-backend.ts +++ b/src/main/browser/offscreen-browser-backend.ts @@ -66,6 +66,7 @@ export class OffscreenBrowserBackend implements BrowserBackend { browserPageId, worktreeId: params.worktreeId, sessionProfileId: profile?.id ?? null, + userAgentMode: profile?.userAgentMode, webContentsId: win.webContents.id }) diff --git a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts index 19bc71dac23..d137a65632e 100644 --- a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts +++ b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts @@ -20,12 +20,7 @@ beforeEach(() => { testState.home = mkdtempSync(join(tmpdir(), 'orca-codex-status-home-')) // Why: the real-home check consults CODEX_HOME and the shell rc, so a // developer who exports one would otherwise fail this suite locally. - for (const key of [ - 'ORCA_USER_DATA_PATH', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', - 'CODEX_HOME', - 'ORCA_CODEX_HOME' - ]) { + for (const key of ['ORCA_USER_DATA_PATH', 'CODEX_HOME', 'ORCA_CODEX_HOME']) { previousEnv[key] = process.env[key] delete process.env[key] } @@ -91,8 +86,8 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { expect(service.getMirroredHostHomePathForStatus()).toBe(account.managedHomePath) }) - it('returns the shared runtime home when the real-home lane is off', async () => { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = '0' + it('returns the shared runtime home when a custom CODEX_HOME keeps the mirror lane', async () => { + process.env.CODEX_HOME = join(testState.home, 'custom-codex-home') const { CodexRuntimeHomeService } = await import('./runtime-home-service') const { getOrcaManagedCodexHomePath } = await import('../codex/codex-home-paths') const service = new CodexRuntimeHomeService(createStore([], null) as never) diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 4012cf3b10c..0efe90bb77c 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -21,7 +21,6 @@ beforeEach(() => { testState.home = mkdtempSync(join(tmpdir(), 'orca-codex-e-home-')) for (const key of [ 'ORCA_USER_DATA_PATH', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_DISABLE_CODEX_TRUST_RPC', 'CODEX_HOME', 'ORCA_CODEX_HOME' diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 693ac8db7cf..f802fecb7e7 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -17,13 +17,13 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' -import type * as NodeFs from 'node:fs' import type * as ShellStartupEnv from '../pty/shell-startup-env' const testState = { userDataDir: '', fakeHomeDir: '', - previousUserDataPath: undefined as string | undefined + previousUserDataPath: undefined as string | undefined, + shellStartupEnvProbeSupported: true } vi.mock('electron', () => ({ @@ -40,34 +40,24 @@ vi.mock('node:os', async () => { } }) -// Why: the shipped code no longer reads a settings flag — the legacy mirror -// lane is reachable only through the test-rig env override. Route the old -// per-test override key to that env var so this suite's lane coverage and -// mid-test flips keep exercising real transitions. +// Why: the shared system-default mirror is still live wherever the shell-startup +// probe is unavailable (Windows), so drive this suite's lane coverage and +// mid-test flips through that real gate rather than a test-only override. type TestSettingsOverrides = Partial & { - codexSystemDefaultRealHomeEnabled?: boolean + shellStartupEnvProbeSupported?: boolean } -function setRealHomeLaneForTest(enabled: boolean): void { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = enabled ? '1' : '0' +function setShellStartupEnvProbeSupportedForTest(enabled: boolean): void { + testState.shellStartupEnvProbeSupported = enabled } -const initialRealHomeLaneEnv = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME -afterEach(() => { - if (initialRealHomeLaneEnv === undefined) { - delete process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME - } else { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = initialRealHomeLaneEnv - } -}) - function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false - // Managed-home tests assert the mirror path; production is real-home always, - // so opt these managed cases out unless a test overrides it. - setRealHomeLaneForTest(overrides.codexSystemDefaultRealHomeEnabled ?? false) + // Mirror-path tests assert the shared runtime home, which production still uses + // on Windows; opt these cases onto that lane unless a test overrides it. + setShellStartupEnvProbeSupportedForTest(overrides.shellStartupEnvProbeSupported ?? false) return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -115,7 +105,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, @@ -320,9 +309,10 @@ describe('CodexRuntimeHomeService', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + testState.shellStartupEnvProbeSupported = true vi.doMock('../pty/shell-startup-env', async () => ({ ...(await vi.importActual('../pty/shell-startup-env')), - isShellStartupEnvProbeSupported: () => true + isShellStartupEnvProbeSupported: () => testState.shellStartupEnvProbeSupported })) testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-runtime-home-')) testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) @@ -367,503 +357,6 @@ describe('CodexRuntimeHomeService', () => { } }) - it('materializes the active managed account auth into the runtime home on startup', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') - expect( - existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) - ).toBe(false) - }) - - it('deselects a shared-home account on settled credential-free JSON', async () => { - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-user', - workspaceLabel: null, - workspaceAccountId: 'acct-user', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - store.updateSettings.mockClear() - - writeFileSync(join(managedHomePath, 'auth.json'), '{}\n', 'utf-8') - service.syncForCurrentSelection() - - expect(store.getSettings().activeCodexManagedAccountId).toBeNull() - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull() - expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe('{}\n') - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-runtime-home] Active managed account credential is unavailable, restoring system default' - ) - }) - - it('deselects a shared-home account when unreadable auth outlives the grace window', async () => { - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-user', - workspaceLabel: null, - workspaceAccountId: 'acct-user', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - store.updateSettings.mockClear() - writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') - const observedAt = Date.now() - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(observedAt) - - service.syncForCurrentSelection() - expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') - expect(store.updateSettings).not.toHaveBeenCalled() - - nowSpy.mockReturnValue(observedAt + 6_000) - service.syncForCurrentSelection() - nowSpy.mockRestore() - expect(store.getSettings().activeCodexManagedAccountId).toBeNull() - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull() - }) - - it.each([ - ['missing', (authPath: string) => rmSync(authPath, { force: true })], - [ - 'unreadable', - (authPath: string) => { - chmodSync(authPath, 0o000) - } - ] - ])( - 'never launches the previously synced account when the selected account auth is %s', - async (label, breakAuth) => { - if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) { - return - } - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const authB = createCodexAuthJson('b@example.com', 'acct-b', 'b') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-a', - email: 'a@example.com', - managedHomePath: homeA, - providerAccountId: 'acct-a', - workspaceLabel: null, - workspaceAccountId: 'acct-a', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-b', - email: 'b@example.com', - managedHomePath: homeB, - providerAccountId: 'acct-b', - workspaceLabel: null, - workspaceAccountId: 'acct-b', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-a', - activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} } - }) - ) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(authA) - - store.updateSettings({ - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - breakAuth(join(homeB, 'auth.json')) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - - // The selection survives the grace window, but the launch must never run - // as account A while the UI says account B. - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( - authA - ) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth' - ) - } - ) - - it.each([ - ['missing', (authPath: string) => rmSync(authPath, { force: true })], - [ - 'unreadable', - (authPath: string) => { - chmodSync(authPath, 0o000) - } - ] - ])( - 'preserves the mirrored refresh of the account the runtime home actually holds when the selected account auth is %s', - async (label, breakAuth) => { - if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) { - return - } - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a', 1) - const authARefreshed = createCodexAuthJson('a@example.com', 'acct-a', 'a-refreshed', 2) - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // A crash left account A's refreshed tokens in the mirror while settings - // already select account B, whose own auth.json cannot be read. - writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, - 'utf-8' - ) - breakAuth(join(homeB, 'auth.json')) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-a', - email: 'a@example.com', - managedHomePath: homeA, - providerAccountId: 'acct-a', - workspaceLabel: null, - workspaceAccountId: 'acct-a', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-b', - email: 'b@example.com', - managedHomePath: homeB, - providerAccountId: 'acct-b', - workspaceLabel: null, - workspaceAccountId: 'acct-b', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - } - ) - - it('keeps the runtime auth through the grace window while the selected account rotates', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-user', - workspaceLabel: null, - workspaceAccountId: 'acct-user', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - - // Codex rewrites the selected account's auth.json in place: the runtime home - // already holds that same account, so its credentials must survive the race. - writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') - }) - - it('keeps runtime auth the selected account still owns when provenance is unreadable', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - // Cold start: the mirror holds the selected account's own credential, but a - // torn provenance file can no longer say so and its auth.json reads torn. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, managedAuth, 'utf-8') - writeFileSync(getSharedRuntimeAuthProvenancePath(), 'not-json', 'utf-8') - writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath) - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') - }) - - it.each([ - ['no provenance', (path: string) => rmSync(path, { force: true })], - [ - 'selected-account provenance', - (path: string) => - writeFileSync( - path, - `${JSON.stringify({ owner: 'managed', accountId: 'account-b' })}\n`, - 'utf-8' - ) - ] - ])('never launches another account on a cold start with %s', async (_label, setProvenance) => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // A retained pane can overwrite the mirror after B's provenance commits. - // Neither missing nor stale provenance can overrule the current bytes. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, authA, 'utf-8') - setProvenance(getSharedRuntimeAuthProvenancePath()) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( - authA - ) - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authA) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - }) - - it('persists a mirror-only system-default refresh before dropping unproven runtime auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system', 1) - const systemAuthRefreshed = createCodexAuthJson( - 'system@example.com', - 'acct-system', - 'system-refreshed', - 2 - ) - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // Codex rotated the system-default credential inside the mirror; ~/.codex is - // still the older copy Orca mirrored, so the refresh lives only here. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, systemAuthRefreshed, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'system-default', authJson: systemAuth })}\n`, - 'utf-8' - ) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuthRefreshed) - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - - it('blocks launch when a readable stale runtime auth cannot be deleted', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // A Windows sharing violation can deny delete while still allowing Codex to - // read the credential, so model those operations independently. - writeFileSync(runtimeAuthPath, authA, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, - 'utf-8' - ) - rmSync(join(homeB, 'auth.json'), { force: true }) - vi.doMock('node:fs', async () => { - const actual = await vi.importActual('node:fs') - return { - ...actual, - rmSync: (path: Parameters[0], options?: Parameters[1]) => { - if (path === runtimeAuthPath) { - throw new Error('simulated Windows sharing violation') - } - return actual.rmSync(path, options) - } - } - }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(() => service.prepareForCodexLaunch()).toThrow( - 'Cannot safely launch Codex while stale runtime auth remains.' - ) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(authA) - expect(JSON.parse(readFileSync(getSharedRuntimeAuthProvenancePath(), 'utf-8'))).toEqual({ - owner: 'fenced' - }) - }) - it('refuses to read runtime auth back into a duplicate account while a home is unreadable', async () => { if (process.platform === 'win32' || process.getuid?.() === 0) { return @@ -911,153 +404,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(homeX2, 'auth.json'), 'utf-8')).toBe(authX) }) - it('drops another account from the mirror even when the fence write fails', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // Stands in for a Windows AV lock on the provenance file: the fence cannot - // be written, while auth.json itself is perfectly deletable. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, authA, 'utf-8') - rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) - mkdirSync(getSharedRuntimeAuthProvenancePath(), { recursive: true }) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( - authA - ) - }) - - it('keeps an unattributable api-key credential Orca mirrored in the same run', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const apiKeyAuth = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-managed' })}\n` - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', apiKeyAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath) - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Exact same-run bytes prove ownership even without identity claims. - writeFileSync(join(managedHomePath, 'auth.json'), '{"OPENAI_API_K', 'utf-8') - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(apiKeyAuth) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') - }) - - it('drops a pre-provenance api-key credential when the selected owner cannot be proven', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-a' })}\n` - const authB = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-b' })}\n` - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB) - writeFileSync(runtimeAuthPath, authA, 'utf-8') - rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authA) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - }) - - it('reads a renamed account refresh back into its own home despite a stale record email', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('new@example.com', 'acct-a', 'a', 1) - const authARefreshed = createCodexAuthJson('new@example.com', 'acct-a', 'a-refreshed', 2) - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // Account A was renamed on the ChatGPT side after it was added, so its record - // email is stale while the mirrored refresh carries the new one. - writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, - 'utf-8' - ) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'old@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed) - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - it('keeps the mirror of a renamed account whose record email is stale', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync( @@ -1108,29 +454,7 @@ describe('CodexRuntimeHomeService', () => { process.platform === 'win32' ? 'junction' : undefined ) writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) @@ -1139,7 +463,7 @@ describe('CodexRuntimeHomeService', () => { normalizeLinkTarget(getRuntimeCodexHomePath()) ) expect(readFileSync(join(legacyActiveHomePath, 'auth.json'), 'utf-8')).toBe( - '{"account":"managed"}\n' + '{"account":"system"}\n' ) }) @@ -1540,42 +864,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') }) - it('removes runtime auth when restoring a no-login system default', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') - - settings.activeCodexManagedAccountId = null - service.syncForCurrentSelection() - - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - it('removes runtime auth when deselecting with a missing system-default snapshot', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') @@ -1652,9 +940,7 @@ describe('CodexRuntimeHomeService', () => { }) }) - it('clears an invalid active account selection and removes untrusted runtime auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + it('clears an active account selection whose self-contained home is missing', async () => { const missingManagedHomePath = join( testState.userDataDir, 'codex-accounts', @@ -1686,47 +972,6 @@ describe('CodexRuntimeHomeService', () => { expect(store.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ activeCodexManagedAccountId: null }) ) - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(warnSpy).toHaveBeenCalled() - }) - - it('clears an invalid active account selection and restores live system default auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = '{"account":"system"}\n' - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - rmSync(runtimeAuthPath, { force: true }) - const missingManagedHomePath = join( - testState.userDataDir, - 'codex-accounts', - 'account-1', - 'home' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath: missingManagedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(store.updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ activeCodexManagedAccountId: null }) - ) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) expect(warnSpy).toHaveBeenCalled() }) @@ -1760,14 +1005,15 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(markerPath)).toBe(false) + // Why: a mirror launch never leaves the real home, so its backfill stays valid. + expect(existsSync(markerPath)).toBe(true) expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) }) - it('routes host system default to the real home when the flag is ON', async () => { - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + it('routes host system default to the real home', async () => { + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -1843,7 +1089,7 @@ describe('CodexRuntimeHomeService', () => { it('seeds shared auth for a pane-local custom home on the real-home lane', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const customHome = join(testState.fakeHomeDir, 'pane-custom-codex-home') @@ -1878,7 +1124,7 @@ describe('CodexRuntimeHomeService', () => { `${JSON.stringify({ owner: 'system-default', authJson: retainedAuth })}\n`, 'utf-8' ) - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -1909,12 +1155,12 @@ describe('CodexRuntimeHomeService', () => { '' ].join('\n') writeFileSync(getSystemCodexAuthPath(), oldSystemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(oldSystemAuth) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') writeFileSync(join(getSystemCodexHomePath(), 'config.toml'), systemConfig, 'utf-8') writeFileSync( @@ -1940,12 +1186,12 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) const restartedService = new CodexRuntimeHomeService(store as never) restartedService.setRealHomeLaneGate(() => true) @@ -1954,43 +1200,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) }) - it('preserves same-identity retained managed auth after restart and source logout', async () => { - const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') - const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'shared@example.com', - managedHomePath, - providerAccountId: 'acct-shared', - workspaceLabel: null, - workspaceAccountId: 'acct-shared', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ] - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - settings.activeCodexManagedAccountId = 'account-1' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } - service.syncForCurrentSelection() - - settings.activeCodexManagedAccountId = null - settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } - setRealHomeLaneForTest(true) - rmSync(getSystemCodexAuthPath()) - const restartedService = new CodexRuntimeHomeService(store as never) - - expect(restartedService.prepareForCodexLaunch()).toBeNull() - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) - }) - it('fences retained shared auth when a self-contained managed transition begins', async () => { const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') @@ -2015,7 +1224,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) settings.activeCodexManagedAccountId = 'account-1' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } service.syncForCurrentSelection() @@ -2062,7 +1271,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) settings.activeCodexManagedAccountId = 'account-1' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } service.syncForCurrentSelection() @@ -2100,14 +1309,14 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) settings.activeCodexManagedAccountId = 'account-1' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } service.syncForCurrentSelection() writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') settings.activeCodexManagedAccountId = null settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } - setRealHomeLaneForTest(false) + setShellStartupEnvProbeSupportedForTest(false) service.syncForCurrentSelection() expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) @@ -2121,12 +1330,12 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) @@ -2136,13 +1345,13 @@ describe('CodexRuntimeHomeService', () => { it('does not rewrite retained-auth provenance during unchanged rate polling', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const provenancePath = getSharedRuntimeAuthProvenancePath() const originalInode = statSync(provenancePath).ino - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.prepareForRateLimitFetch() service.prepareForRateLimitFetch() @@ -2152,10 +1361,10 @@ describe('CodexRuntimeHomeService', () => { it('does not rewrite completed retained logout metadata during rate polling', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) service.prepareForRateLimitFetch() const metadataPaths = [ @@ -2213,7 +1422,7 @@ describe('CodexRuntimeHomeService', () => { ) settings.activeCodexManagedAccountId = null settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.syncForCurrentSelection() writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') @@ -2232,7 +1441,7 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') @@ -2247,11 +1456,11 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) const service = new CodexRuntimeHomeService(store as never) writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') rmSync(getSystemCodexAuthPath()) @@ -2268,7 +1477,7 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync( @@ -2280,7 +1489,7 @@ describe('CodexRuntimeHomeService', () => { })}\n` ) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) @@ -2289,14 +1498,14 @@ describe('CodexRuntimeHomeService', () => { it('recovers runtime auth quarantined by an interrupted guarded update', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) const runtimeAuthPath = getRuntimeCodexAuthPath() const heldAuthPath = `${runtimeAuthPath}.orca-guarded` renameSync(runtimeAuthPath, heldAuthPath) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) new CodexRuntimeHomeService(store as never) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) @@ -2307,7 +1516,7 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') const retainedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'retained') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync( @@ -2320,7 +1529,7 @@ describe('CodexRuntimeHomeService', () => { ) writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) @@ -2334,12 +1543,12 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync(getSharedRuntimeAuthProvenancePath(), '{"owner":"pending"}\n') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) @@ -2349,11 +1558,11 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) const service = new CodexRuntimeHomeService(store as never) rmSync(getSystemCodexAuthPath()) expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) @@ -2373,11 +1582,11 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) rmSync(getRuntimeCodexAuthPath()) if (removeProvenance) { @@ -2408,7 +1617,7 @@ describe('CodexRuntimeHomeService', () => { createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') ) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: false, + shellStartupEnvProbeSupported: false, codexManagedAccounts: [ { id: 'account-1', @@ -2428,7 +1637,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) rmSync(getRuntimeCodexAuthPath()) rmSync( @@ -2458,14 +1667,14 @@ describe('CodexRuntimeHomeService', () => { const systemConfigPath = join(getSystemCodexHomePath(), 'config.toml') const runtimeConfigPath = join(getRuntimeCodexHomePath(), 'config.toml') writeFileSync(systemConfigPath, 'model = "baseline"\n', 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) writeFileSync(runtimeConfigPath, 'model = "runtime-change"\n', 'utf-8') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.setRealHomeLaneGate(() => false) service.reconcileLegacySharedHomeForRetainedPanes() expect(readFileSync(systemConfigPath, 'utf-8')).toBe('model = "baseline"\n') @@ -2476,7 +1685,7 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') }) - it('routes a host MANAGED account to its own self-contained home when the flag is ON', async () => { + it('routes a host MANAGED account to its own self-contained home', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const runtimeAuthPath = getRuntimeCodexAuthPath() const managedHomePath = createManagedAuth( @@ -2486,7 +1695,7 @@ describe('CodexRuntimeHomeService', () => { ) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2507,7 +1716,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Flag ON + host managed account = the account's own home is CODEX_HOME. + // A host managed account's own home is its CODEX_HOME. expect(service.isHostSystemDefaultRealHome()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) // The per-account home keeps its own auth in place; the shared mirror's @@ -2520,14 +1729,14 @@ describe('CodexRuntimeHomeService', () => { expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(managedHomePath) }) - it('gives two managed accounts distinct homes without racing one auth.json (flag ON)', async () => { + it('gives two managed accounts distinct homes without racing one auth.json', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) const home2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2579,7 +1788,7 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) }) - it('materializes resources and config into the per-account home on launch (flag ON)', async () => { + it('materializes resources and config into the per-account home on launch', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true }) writeFileSync( @@ -2599,7 +1808,7 @@ describe('CodexRuntimeHomeService', () => { ) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2629,11 +1838,11 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe('{"account":"system"}\n') }) - it('points the rate-limit fetch at the per-account home when the flag is ON', async () => { + it('points the rate-limit fetch at the per-account home', async () => { const home1 = createManagedAuth(testState.userDataDir, 'account-1', '{"account":"managed"}\n') const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2657,14 +1866,14 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch()).toBe(home1) }) - it('preserves a managed selection whose auth.json is temporarily missing (flag ON)', async () => { + it('preserves a managed selection whose auth.json is temporarily missing', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') // A managed home that has lost its auth.json (only the marker remains). const brokenHome = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home') mkdirSync(brokenHome, { recursive: true }) writeFileSync(join(brokenHome, '.orca-managed-home'), 'account-1\n', 'utf-8') const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2690,41 +1899,9 @@ describe('CodexRuntimeHomeService', () => { expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') }) - it('keeps the shared runtime home + auth hot-swap for managed accounts when the flag is OFF', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') - const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const store = createStore( - createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Flag OFF is byte-identical to today: shared mirror home + hot-swapped auth. - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1Auth) - }) - - it('keeps pre-E shared-mirror sessions discoverable alongside per-account rollouts (flag ON)', async () => { + it('keeps pre-E shared-mirror sessions discoverable alongside per-account rollouts', async () => { // Pre-E history lives in the shared runtime mirror; after upgrading to - // per-account homes (flag ON) new rollouts land in the account's own home. + // per-account homes, new rollouts land in the account's own home. const sharedSessionsDir = join(getRuntimeCodexHomePath(), 'sessions', '2026', '07', '16') mkdirSync(sharedSessionsDir, { recursive: true }) writeFileSync(join(sharedSessionsDir, 'rollout-pre-e.jsonl'), '{"record":"pre-e"}\n', 'utf-8') @@ -2742,7 +1919,7 @@ describe('CodexRuntimeHomeService', () => { ) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2770,9 +1947,9 @@ describe('CodexRuntimeHomeService', () => { expect(discovery).toContain(home1) }) - it('surfaces per-account rollouts for session discovery after opting out (flag OFF)', async () => { - // The account ran with the flag ON and accumulated rollouts in its own home, - // then the user opted back out (flag OFF, byte-identical launch path). + it('surfaces per-account rollouts for session discovery on the mirror lane', async () => { + // A Windows host keeps the shared system-default mirror, but its managed + // accounts still launch from their own homes and accumulate rollouts there. const home1 = createManagedAuth( testState.userDataDir, 'account-1', @@ -2783,7 +1960,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(join(rolloutDir, 'rollout-e-era.jsonl'), '{"record":"e-era"}\n', 'utf-8') const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: false, + shellStartupEnvProbeSupported: false, codexManagedAccounts: [ { id: 'account-1', @@ -2804,118 +1981,10 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Opting out must not hide rollouts that already live in the per-account home. + // The mirror lane must not hide rollouts living in the per-account home. expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(home1) }) - it('does not scan an opted-out never-enabled account home that holds no rollouts', async () => { - // A pure never-enabled install keeps its per-account homes credential-only, so - // opt-out discovery must stay byte-identical to today (mirror home only). - const home1 = createManagedAuth( - testState.userDataDir, - 'account-1', - createCodexAuthJson('one@example.com', 'acct-1', 'one') - ) - const store = createStore( - createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.getHostCodexHomePathsForSessionDiscovery()).not.toContain(home1) - }) - - it('preserves a fresh per-account credential and propagates it to the shared mirror on opt-out', async () => { - // Flag ON refreshed the account's token in its own home; the shared mirror's - // auth is frozen from before the ON period. Opting out (flag OFF) must not let - // the stale mirror token clobber the fresher per-account credential. - const freshAuth = createCodexAuthJson('one@example.com', 'acct-1', 'fresh', 4_000) - const staleAuth = createCodexAuthJson('one@example.com', 'acct-1', 'stale', 1_000) - const home1 = createManagedAuth(testState.userDataDir, 'account-1', freshAuth) - // A stale mirror auth left over from before the account ran with the flag ON. - writeFileSync(getRuntimeCodexAuthPath(), staleAuth, 'utf-8') - const store = createStore( - createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - // Constructor runs the startup opt-out sync (read-back + hot-swap). - new CodexRuntimeHomeService(store as never) - - // The fresher per-account credential is preserved (never overwritten by the - // stale mirror read-back) and becomes the mirror's auth for the opted-out launch. - expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(freshAuth) - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(freshAuth) - }) - - it('rolls a live self-contained account back without adopting newer shared bytes', async () => { - const accountAuth = createCodexAuthJson('one@example.com', 'acct-1', 'in-place', 2_000) - const laterShared = createCodexAuthJson('one@example.com', 'acct-1', 'shared', 3_000) - const home1 = createManagedAuth(testState.userDataDir, 'account-1', accountAuth) - const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch()).toBe(home1) - writeFileSync(getRuntimeCodexAuthPath(), laterShared, 'utf-8') - - setRealHomeLaneForTest(false) - service.syncForCurrentSelection() - - expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(accountAuth) - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(accountAuth) - }) - it('keeps per-account auth canonical when the real-home lane takes over', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') @@ -2929,7 +1998,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2950,7 +2019,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Flag ON launches the account from its own home without populating the + // Launching the account from its own home never populates the // legacy shared mirror. expect(service.isHostSystemDefaultRealHome()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) @@ -2983,7 +2052,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3031,7 +2100,7 @@ describe('CodexRuntimeHomeService', () => { createCodexAuthJson('wsl@example.com', 'acct-wsl', 'wsl') ) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3138,7 +2207,7 @@ describe('CodexRuntimeHomeService', () => { const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', accountAuth) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3199,7 +2268,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3245,7 +2314,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3281,328 +2350,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) }) - it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - const account1Home = service.prepareForCodexLaunch() - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - const account2Home = service.prepareForCodexLaunch() - - expect(account1Home).toBe(getRuntimeCodexHomePath()) - expect(account2Home).toBe(getRuntimeCodexHomePath()) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('new host Codex launches get the selected account after preserving outgoing refreshes', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-1', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('reads back selected-account refreshes without ambiguity from duplicate identities', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'same@example.com', - 'acct-same', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) - }) - - it('keeps fresher selected-account startup refreshes when duplicate identities exist', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'same@example.com', - 'acct-same', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) - }) - - it('routes stale live-pane startup refreshes to the matching account before restoring selected auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-1', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-2', - activeCodexManagedAccountIdsByRuntime: { host: 'account-2', wsl: {} } - }) - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('preserves duplicate-identity outgoing refreshes before switching to another account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'same@example.com', - 'acct-same', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) - const account3Auth = createCodexAuthJson('three@example.com', 'acct-3', 'three', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', account3Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - }, - { - id: 'account-3', - email: 'three@example.com', - managedHomePath: managedHomePath3, - providerAccountId: 'acct-3', - workspaceLabel: null, - workspaceAccountId: 'acct-3', - createdAt: 3, - updatedAt: 3, - lastAuthenticatedAt: 3 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - settings.activeCodexManagedAccountId = 'account-3' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-3', wsl: {} } - service.syncForCurrentSelection() - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account3Auth) - }) - it('mirrors later system Codex config changes before launch', async () => { const systemCodexHome = getSystemCodexHomePath() mkdirSync(systemCodexHome, { recursive: true }) @@ -3632,7 +2379,7 @@ describe('CodexRuntimeHomeService', () => { '' ].join('\n') writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -4129,7 +2876,6 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(hostAuth) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( wslRuntimeHomePath ) @@ -4935,38 +3681,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) }) - it('removes untrusted runtime auth on restart when persisted active account is invalid', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath: join(testState.userDataDir, 'codex-accounts', 'account-1', 'home'), - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(store.updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ activeCodexManagedAccountId: null }) - ) - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(warnSpy).toHaveBeenCalled() - }) - it('imports legacy managed-home history into the shared runtime history', async () => { const runtimeHomePath = getRuntimeCodexHomePath() const runtimeHistoryPath = join(runtimeHomePath, 'history.jsonl') @@ -5000,30 +3714,8 @@ describe('CodexRuntimeHomeService', () => { } const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) @@ -5038,29 +3730,8 @@ describe('CodexRuntimeHomeService', () => { } const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -5143,385 +3814,6 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(snapshotPath)).toBe(false) }) - it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Simulate CLI refreshing the token in runtime CODEX_HOME/auth.json. - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - - // Next sync should read back the refreshed token to managed storage - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - }) - - it('rejects runtime read-back from a different Codex identity', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') - const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'selected@example.com', - managedHomePath, - providerAccountId: 'acct-selected', - workspaceLabel: null, - workspaceAccountId: 'acct-selected', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Simulate an old live Codex PTY from another account refreshing the - // shared runtime auth after Orca has already selected account-1. - writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) - }) - - it('routes runtime read-back from a different Codex identity to its matching account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-one', - 'one-refreshed' - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-2' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // An older account-1 Codex process refreshed the shared runtime file after - // Orca selected account-2. Persist the refresh to account-1, then restore - // the selected account in runtime CODEX_HOME. - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('rejects ambiguous Codex read-back instead of choosing a managed account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('same@example.com', 'acct-same', 'original') - const refreshedAuth = createCodexAuthJson('same@example.com', 'acct-same', 'refreshed') - const activeAuth = createCodexAuthJson('active@example.com', 'acct-active', 'active') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', originalAuth) - const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', activeAuth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - }, - { - id: 'account-3', - email: 'active@example.com', - managedHomePath: managedHomePath3, - providerAccountId: 'acct-active', - workspaceLabel: null, - workspaceAccountId: 'acct-active', - createdAt: 3, - updatedAt: 3, - lastAuthenticatedAt: 3 - } - ], - activeCodexManagedAccountId: 'account-3' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(originalAuth) - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(originalAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(activeAuth) - }) - - it('rejects runtime read-back without a positive selected-account identity match', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') - const accountOnlyAuth = `${JSON.stringify({ - tokens: { - account_id: 'acct-stale', - refresh_token: 'stale' - } - })}\n` - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'selected@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, accountOnlyAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) - }) - - it('rejects same-email runtime read-back when account ids differ from sparse managed metadata', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const selectedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'selected') - const staleLivePtyAuth = createCodexAuthJson('user@example.com', 'acct-stale', 'stale') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) - }) - - it('reads back same-account refreshes for sparse managed metadata using stored auth identity', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'original') - const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'refreshed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - }) - - it('reads back strong account-id refreshes when the runtime auth has no email', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const refreshedAuth = `${JSON.stringify({ - tokens: { - account_id: 'acct-1', - refresh_token: 'refreshed' - } - })}\n` - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - }) - - it('rejects unverifiable Codex read-back on first sync after restart', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"credential":"refreshed-while-down"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"credential":"original"}\n' - ) - const managedAuthPath = join(managedHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"credential":"original"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"credential":"original"}\n') - }) - it('reads back verified same-account refreshes on first sync after restart', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original', 1_000) @@ -5555,201 +3847,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) }) - it('rejects older same-account Codex auth on first sync after restart', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const staleRuntimeAuth = createCodexAuthJson('user@example.com', 'acct-1', 'stale', 1_000) - const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed-newer', 2_000) - writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(managedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - }) - - it('does not contaminate the incoming Codex account during account switch', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath1 = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"credential":"account1"}\n' - ) - const managedHomePath2 = createManagedAuth( - testState.userDataDir, - 'account-2', - '{"credential":"account2"}\n' - ) - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user1@example.com', - managedHomePath: managedHomePath1, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'user2@example.com', - managedHomePath: managedHomePath2, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - settings.activeCodexManagedAccountId = 'account-2' - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe('{"credential":"account2"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"credential":"account2"}\n') - }) - - it('does not carry the reauth read-back skip across Codex account switches', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const account2RefreshedAuth = createCodexAuthJson( - 'two@example.com', - 'acct-two', - 'two-refreshed' - ) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - service.clearLastWrittenAuthJson() - settings.activeCodexManagedAccountId = 'account-2' - service.syncForCurrentSelection() - - writeFileSync(runtimeAuthPath, account2RefreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2RefreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2RefreshedAuth) - }) - - it('does not apply inactive-account Codex reauth skip to the active account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-one', - 'one-refreshed' - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - service.clearLastWrittenAuthJson('account-2') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) - }) - it('restores system default when unverified runtime auth appears before deselect', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') @@ -5795,44 +3892,6 @@ describe('CodexRuntimeHomeService', () => { ).toBe(true) }) - it('restores system default after same-identity managed Codex refresh on deselect', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') - const externalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'external') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ] - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - settings.activeCodexManagedAccountId = 'account-1' - service.syncForCurrentSelection() - - writeFileSync(runtimeAuthPath, externalAuth, 'utf-8') - settings.activeCodexManagedAccountId = null - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(externalAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-old"}\n') - }) - it('restores system default when stale Codex credentials are rejected on deselect', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') @@ -5907,78 +3966,19 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('keeps external system-default logout when managed runtime auth still exists', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') - rmSync(getSystemCodexAuthPath(), { force: true }) - settings.activeCodexManagedAccountId = null - service.syncForCurrentSelection() - - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - - it('recreates retained auth after switching from managed to logged-out system default', async () => { + it('recreates retained auth after a logged-out system default logs back in', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'managed@example.com', - managedHomePath, - providerAccountId: 'acct-managed', - workspaceLabel: null, - workspaceAccountId: 'acct-managed', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) rmSync(getSystemCodexAuthPath()) - settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.setRealHomeLaneGate(() => true) writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') service.reconcileLegacySharedHomeForRetainedPanes() @@ -6027,95 +4027,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-2"}\n') }) - it('reads back refreshed tokens for the outgoing Codex account before switching', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Original = createCodexAuthJson('one@example.com', 'acct-1', 'one-original') - const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Original) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1Refreshed, 'utf-8') - settings.activeCodexManagedAccountId = 'account-2' - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1Refreshed) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const reauthedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'reauthed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Simulate re-auth: managed storage gets fresh tokens - writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') - - // Clear tracking before sync (as CodexAccountService would) - service.clearLastWrittenAuthJson() - service.syncForCurrentSelection() - - // Fresh re-auth tokens should survive — not be clobbered by stale runtime read-back - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) - }) - it('preserves conflicting legacy session files under deterministic names', async () => { const runtimeSessionsDir = join(getRuntimeCodexHomePath(), 'sessions') mkdirSync(runtimeSessionsDir, { recursive: true }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index fa2f27798b6..c2b99b52c6d 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -67,7 +67,6 @@ import { type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' @@ -176,8 +175,8 @@ export class CodexRuntimeHomeService { private readonly lastSyncedWslAccountIdByDistro = new Map() private readonly wslRuntimeHomePathByDistro = new Map() private skipNextReadBackForAccountId: string | null = null - // Why: a flag-ON host account refreshes auth in its own home. Remember that - // provenance so a later deselect/rollback never adopts stale shared bytes. + // Why: a managed host account refreshes auth in its own home. Remember that + // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false private sharedAuthRefreshBlockedByManagedTransition = false // Why: transient auth.json read/parse failures must not deselect an account. @@ -236,7 +235,7 @@ export class CodexRuntimeHomeService { // system default without injecting a path Orca cannot prove it owns. } if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why (flag ON, system default): run Codex on the user's own ~/.codex. + // Why: the system default runs Codex on the user's own ~/.codex. // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; // the retired mirror is refreshed only for pre-rollout PTYs. this.reconcileLegacySharedHomeForRetainedPanes() @@ -254,17 +253,12 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } - // Why: with the real-home flag ON, a managed HOST account runs against its own - // self-contained CODEX_HOME (codex-accounts//home) instead of the shared - // runtime mirror. Its auth.json lives there and codex refreshes it in place, - // so two accounts never race one auth.json (GAP-5) and the mirror can be - // deleted once no lane still injects it (GAP-1). WSL accounts keep their - // per-distro lane; the flag-OFF opt-out keeps the shared-home hot-swap. + // Why: a managed HOST account runs against its own self-contained CODEX_HOME + // (codex-accounts//home) rather than the shared runtime mirror. Its + // auth.json lives there and codex refreshes it in place, so two accounts never + // race one auth.json. WSL accounts keep their per-distro lane. private getSelfContainedManagedHostAccount(): CodexManagedAccount | null { const settings = this.store.getSettings() - if (!isCodexSystemDefaultRealHomeEnabled()) { - return null - } const account = this.getActiveAccount( settings.codexManagedAccounts, normalizeCodexRuntimeSelection(settings).host @@ -276,22 +270,17 @@ export class CodexRuntimeHomeService { } // Why: session discovery must surface a managed account's own rollouts wherever - // they physically live. Flag ON makes every host managed home a live CODEX_HOME, - // so scan them all. Flag OFF (opt-out/rollback) hands launches back to the shared - // mirror, but a home that already accumulated rollouts while the flag was ON must - // still surface them — otherwise opting out silently hides history that is safe on - // disk. Gate the flag-OFF case on a sessions/ tree so a never-enabled install stays - // byte-identical to today (its per-account homes hold only auth, no rollouts). + // they physically live. Every host managed home is a live CODEX_HOME, so scan + // them all. private getManagedHostAccountHomesForSessionDiscovery(): string[] { const settings = this.store.getSettings() - const flagEnabled = isCodexSystemDefaultRealHomeEnabled() const homes: string[] = [] for (const account of settings.codexManagedAccounts) { if (this.getWslManagedHomePath(account)) { continue } const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome && (flagEnabled || existsSync(join(trustedHome, 'sessions')))) { + if (trustedHome) { homes.push(trustedHome) } } @@ -420,8 +409,9 @@ export class CodexRuntimeHomeService { if (normalizeCodexRuntimeSelection(settings).host !== null) { return } - const realHomeSelected = this.isHostSystemDefaultRealHomeSelected(launchEnv) - if (realHomeSelected || !isCodexSystemDefaultRealHomeEnabled()) { + // Why: reached only when the real-home lane is selected but its gate is off, + // so the launch runs on the mirror and the backfill marker is stale. + if (this.isHostSystemDefaultRealHomeSelected(launchEnv)) { invalidateCodexSessionBackfillMarker( join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json') ) @@ -463,10 +453,9 @@ export class CodexRuntimeHomeService { // mirror, so include the real root for both directly-routed host lanes. homes.push(getSystemCodexHomePath()) } - // Why: flag ON routes each managed host account to its own self-contained - // home, so its rollouts live there rather than in the shared mirror. Scan - // every such home — plus any that retained rollouts across an opt-out — so - // account-scoped sessions still surface in the AI Vault. + // Why: each managed host account runs in its own self-contained home, so + // its rollouts live there rather than in the shared mirror. Scan every such + // home so account-scoped sessions still surface in the AI Vault. for (const perAccountHome of this.getManagedHostAccountHomesForSessionDiscovery()) { homes.push(perAccountHome) } @@ -475,9 +464,8 @@ export class CodexRuntimeHomeService { /** * The account-owned CODEX_HOME the current HOST selection runs against, or - * null when the selection is not routed to one (system default, or the - * flag-OFF shared mirror, which every account hot-swaps and so names no - * account). + * null when the selection is not routed to one (system default, or a WSL + * account, whose home lives inside the distro). * * Read-only on purpose: session discovery ranks homes with this before any * launch prep, so it must create no directories and sync no auth. @@ -505,13 +493,13 @@ export class CodexRuntimeHomeService { this.realHomeLaneGate = gate } - // Why: real-home routing applies only to the host system-default selection - // with the staged flag ON. Managed accounts keep hot-swap isolation; custom - // CODEX_HOMEs stay managed until phase 1 can track cleanup across old homes. + // Why: real-home routing applies only to the host system-default selection. + // Managed accounts run in their own homes; Windows (no shell-startup probe) + // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked + // across old homes. isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { const settings = this.store.getSettings() if ( - !isCodexSystemDefaultRealHomeEnabled() || normalizeCodexRuntimeSelection(settings).host !== null || !isShellStartupEnvProbeSupported() ) { @@ -640,9 +628,9 @@ export class CodexRuntimeHomeService { } const settings = this.store.getSettings() if (this.lastHostAccountUsedSelfContainedHome) { - // Why: E auth is already canonical in the per-account home. Reset the - // legacy mirror baseline without reading it; flag-OFF can then seed the - // mirror from canonical storage, while real-home deselect needs no sync. + // Why: the account's auth is already canonical in its own home. Reset the + // legacy mirror baseline without reading it; a real-home deselect needs no + // further sync, and the mirror lane below re-seeds from canonical storage. this.lastHostAccountUsedSelfContainedHome = false this.lastSyncedAccountId = null this.lastWrittenAuthJson = null @@ -668,105 +656,16 @@ export class CodexRuntimeHomeService { settings.codexManagedAccounts, normalizeCodexRuntimeSelection(settings).host ) - const previousAccount = this.getActiveAccount( - settings.codexManagedAccounts, - this.lastSyncedAccountId - ) - if (this.getWslManagedHomePath(activeAccount)) { - const previousWasHostManaged = previousAccount && !this.getWslManagedHomePath(previousAccount) - const outgoingReadBackResult = previousWasHostManaged - ? this.readBackRefreshedTokensForAccount(previousAccount, { - updateLastWrittenAuthJson: false - }) - : 'unchanged' - if (previousWasHostManaged) { - this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult !== 'rejected' - }) - } + if (activeAccount) { + // Why: only a WSL-managed account can reach here — every host account was + // routed to its own self-contained home above. Its auth lives in the + // distro-local runtime home, so the host mirror only drops its baseline. this.lastSyncedAccountId = null this.lastWrittenAuthJson = null this.skipNextReadBackForAccountId = null return } - let outgoingReadBackResult: CodexReadBackResult = 'unchanged' - if (previousAccount && previousAccount.id !== activeAccount?.id) { - outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, { - updateLastWrittenAuthJson: true - }) - } - if (!activeAccount) { - if (normalizeCodexRuntimeSelection(settings).host) { - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - } - // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult !== 'rejected' - }) - this.lastSyncedAccountId = null - } else if (!runtimeAuthExistedBeforeSync) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - if (logoutMarkerStatus.kind === 'applies') { - this.lastWrittenAuthJson = null - } else if ( - logoutMarkerStatus.kind === 'system-default-changed' && - logoutMarkerStatus.systemDefaultAuthJson !== null - ) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else if (logoutMarkerStatus.kind === 'system-default-changed') { - // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - } else if (this.lastWrittenAuthJson === null) { - // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else { - this.persistRuntimeLogoutMarker() - } - } else { - this.clearRuntimeLogoutMarker() - this.syncRuntimeAuthWithSystemDefault() - } - return - } - - const activeAuthPath = join(activeAccount.managedHomePath, 'auth.json') - const authAbsence = this.credentialAbsenceGrace.assess(activeAuthPath) - if (authAbsence.state !== 'present' && authAbsence.state !== 'incomplete') { - if (!authAbsence.durable) { - if (this.sharedRuntimeAuthBelongsToAccount(activeAccount)) { - // Why: mid-rotation reads look missing/unreadable for a moment; skip - // this sync without deselecting and let a settled read decide later. - console.warn( - '[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window' - ) - return - } - // Why: the runtime home still holds another account, so riding out the - // grace would launch that account under this selection. Not being able - // to read the selected account is no license to run a different one. - console.warn( - '[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth' - ) - this.clearRuntimeAuthForUnprovenSelection() - return - } - console.warn( - '[codex-runtime-home] Active managed account credential is unavailable, restoring system default' - ) - // Why: valid credential-free JSON is an explicit logout; never revive it - // from stale shared-home bytes while clearing the selection. - if (authAbsence.state !== 'no-credential' && this.lastSyncedAccountId === activeAccount.id) { - outgoingReadBackResult = this.recoverRefreshForMissingActiveAccount(activeAccount) - } + if (normalizeCodexRuntimeSelection(settings).host) { this.store.updateSettings({ activeCodexManagedAccountId: null, activeCodexManagedAccountIdsByRuntime: { @@ -774,38 +673,35 @@ export class CodexRuntimeHomeService { host: null } }) - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult !== 'rejected' - }) - this.lastSyncedAccountId = null - } - return } - - if (this.lastSyncedAccountId === null) { - this.captureSystemDefaultSnapshot({ force: true }) - } - - // Why: Codex refreshes OAuth tokens in the runtime auth.json; if it differs from Orca's last write, read those back to managed storage before overwriting. - if (this.lastSyncedAccountId === activeAccount.id) { - if (this.skipNextReadBackForAccountId === activeAccount.id) { - this.skipNextReadBackForAccountId = null + // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. + if (this.lastSyncedAccountId !== null) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + this.lastSyncedAccountId = null + } else if (!runtimeAuthExistedBeforeSync) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if (logoutMarkerStatus.kind === 'applies') { + this.lastWrittenAuthJson = null + } else if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else if (logoutMarkerStatus.kind === 'system-default-changed') { + // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + } else if (this.lastWrittenAuthJson === null) { + // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) } else { - this.readBackRefreshedTokens({ - updateLastWrittenAuthJson: true - }) + this.persistRuntimeLogoutMarker() } + } else { + this.clearRuntimeLogoutMarker() + this.syncRuntimeAuthWithSystemDefault() } - - if (this.lastSyncedAccountId !== activeAccount.id) { - this.skipNextReadBackForAccountId = null - } - this.lastSyncedAccountId = activeAccount.id - this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'), { - owner: 'managed', - accountId: activeAccount.id - }) } // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. @@ -818,26 +714,6 @@ export class CodexRuntimeHomeService { this.skipNextReadBackForAccountId = accountId } - private readBackRefreshedTokens(options: { - updateLastWrittenAuthJson: boolean - }): CodexReadBackResult { - const selectedAccountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host - if (selectedAccountId) { - const selectedAccountResult = this.readBackRefreshedTokensFromPath( - this.getRuntimeAuthPath(), - { - ...options, - expectedAccountId: selectedAccountId - } - ) - if (selectedAccountResult !== 'rejected') { - return selectedAccountResult - } - } - - return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), options) - } - private readBackRefreshedTokensFromPath( runtimeAuthPath: string, options: { @@ -895,119 +771,6 @@ export class CodexRuntimeHomeService { } } - private readBackRefreshedTokensForAccount( - account: CodexManagedAccount, - options: { updateLastWrittenAuthJson: boolean } - ): CodexReadBackResult { - return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), { - ...options, - expectedAccountId: account.id - }) - } - - private recoverRefreshForMissingActiveAccount(account: CodexManagedAccount): CodexReadBackResult { - try { - const runtimeAuthPath = this.getRuntimeAuthPath() - if (!existsSync(runtimeAuthPath) || this.lastWrittenAuthJson === null) { - return 'rejected' - } - const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (runtimeContents === this.lastWrittenAuthJson) { - return 'unchanged' - } - // Why: the canonical file is gone, so the exact in-memory bytes Orca - // previously mirrored are the only safe identity baseline for recovery. - if (!codexAuthMatchesManagedAccount(runtimeContents, account, this.lastWrittenAuthJson)) { - return 'rejected' - } - writeFileAtomically(join(account.managedHomePath, 'auth.json'), runtimeContents, { - mode: 0o600 - }) - this.lastWrittenAuthJson = runtimeContents - return 'persisted' - } catch (error) { - console.warn('[codex-runtime-home] Failed to recover missing managed auth:', error) - return 'rejected' - } - } - - // Why: stale panes can overwrite the mirror after provenance is committed, so - // launch ownership needs current-byte identity or Orca's exact same-run write. - private sharedRuntimeAuthBelongsToAccount(account: CodexManagedAccount): boolean { - if (!existsSync(this.getRuntimeAuthPath())) { - return true - } - const runtimeAuth = this.readRuntimeAuthForProvenance() - if (runtimeAuth !== null) { - if (codexAuthMatchesManagedAccount(runtimeAuth, account, null)) { - return true - } - // Why: Orca itself mirrored these exact bytes for this account this run. - if (this.lastSyncedAccountId === account.id && this.lastWrittenAuthJson === runtimeAuth) { - return true - } - } - return false - } - - // Why: the absence may still heal, so keep the selection — but leave no other - // identity's credentials behind for the launch. Logged out beats logged in as - // someone else, and fencing stops later syncs adopting the removed bytes. - private clearRuntimeAuthForUnprovenSelection(): void { - const runtimeAuthPath = this.getRuntimeAuthPath() - try { - // Why: a refresh Codex wrote into the mirror belongs to whoever owns - // those bytes; persist it to that owner's home — managed or ~/.codex — - // first, then fence so later syncs cannot adopt the removed bytes. - const readBackResult = this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: false - }) - if (readBackResult === 'rejected') { - this.readBackRefreshedSystemDefaultAuth() - } - this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) - } catch (error) { - // Why: rescue and metadata are best-effort; neither may leave another - // identity's credentials in the home returned to the launch. - console.warn('[codex-runtime-home] Failed to rescue or fence unproven runtime auth:', error) - } - this.lastWrittenAuthJson = null - try { - rmSync(runtimeAuthPath, { force: true }) - } catch (error) { - // Why: a fence is Orca metadata that Codex does not read. If Windows or - // another host keeps auth.json locked, failing launch is the safe result. - throw new Error('Cannot safely launch Codex while stale runtime auth remains.', { - cause: error - }) - } - } - - // Why: a token Codex refreshed inside the mirror has no managed home to fall - // back to when the mirror holds the user's own ~/.codex credential, so persist - // it there before the unproven mirror is dropped. - private readBackRefreshedSystemDefaultAuth(): void { - const runtimeAuth = this.readRuntimeAuthForProvenance() - const systemDefaultAuth = this.readSystemDefaultAuth() - if (runtimeAuth === null || systemDefaultAuth === null || runtimeAuth === systemDefaultAuth) { - return - } - const claim = this.resolveSystemDefaultMirrorClaim( - runtimeAuth, - this.resolveSharedRuntimeAuthProvenanceStatus() - ) - if ( - !claim.ownershipProven || - claim.mirroredAuthJson === null || - systemDefaultAuth !== claim.mirroredAuthJson || - !this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemDefaultAuth) - ) { - return - } - this.writeSystemDefaultAuth(runtimeAuth) - this.captureSystemDefaultSnapshot({ force: true }) - } - // Why: which ~/.codex bytes the mirror was seeded from, and whether the system // default can be proven to own the mirror at all. private resolveSystemDefaultMirrorClaim( @@ -1399,9 +1162,6 @@ export class CodexRuntimeHomeService { private safeMigrateLegacySharedAuth(): void { const settings = this.store.getSettings() - if (!isCodexSystemDefaultRealHomeEnabled()) { - return - } try { migrateLegacySharedAuthToPerAccountHome({ activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, diff --git a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts index a1a66a847d8..54ab5d5f095 100644 --- a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts +++ b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts @@ -3,7 +3,6 @@ import type { GlobalSettings } from '../../shared/types' import { CodexRuntimeHomeService } from './runtime-home-service' const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -const originalRealHomeOverride = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME const originalCodexHome = process.env.CODEX_HOME const originalOrcaCodexHome = process.env.ORCA_CODEX_HOME @@ -11,7 +10,6 @@ afterEach(() => { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } - restoreEnv('ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', originalRealHomeOverride) restoreEnv('CODEX_HOME', originalCodexHome) restoreEnv('ORCA_CODEX_HOME', originalOrcaCodexHome) }) @@ -19,7 +17,6 @@ afterEach(() => { describe('Windows System Default Codex home ownership', () => { it('stays managed when PowerShell profile state cannot be inspected', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = '1' delete process.env.CODEX_HOME delete process.env.ORCA_CODEX_HOME diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 175a42e6dbd..79bd2ea7bbe 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -46,33 +46,10 @@ function decodeEncodedWslBashCommand(command: string): string { return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command } -// Why: the shipped code no longer reads a settings flag — the legacy mirror -// lane is reachable only through the test-rig env override. Route the old -// per-test override key to that env var so lane coverage keeps working. -type TestSettingsOverrides = Partial & { - codexSystemDefaultRealHomeEnabled?: boolean -} - -function setRealHomeLaneForTest(enabled: boolean): void { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = enabled ? '1' : '0' -} - -const initialRealHomeLaneEnv = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME -afterEach(() => { - if (initialRealHomeLaneEnv === undefined) { - delete process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME - } else { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = initialRealHomeLaneEnv - } -}) - -function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { +function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false - // Config-sync/hot-swap tests assert the shared-mirror path; production is - // real-home always, so opt these managed cases out unless a test overrides it. - setRealHomeLaneForTest(overrides.codexSystemDefaultRealHomeEnabled ?? false) return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -120,7 +97,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, @@ -430,7 +406,6 @@ describe('CodexAccountService config sync', () => { 'approval_policy = "on-request"\n' ) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, codexManagedAccounts: [ { id: 'account-1', @@ -476,43 +451,6 @@ describe('CodexAccountService config sync', () => { expectSanitizedManagedConfig() }) - it('keeps flag-off config mirroring byte-identical', async () => { - const fixture = await createCanonicalHookTrustFixture() - const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') - writeFileSync(canonicalConfigPath, fixture.config, 'utf-8') - const managedHomePath = createManagedHome( - testState.userDataDir, - 'account-1', - 'approval_policy = "on-request"\n' - ) - const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ] - }) - - const { CodexAccountService } = await import('./service') - new CodexAccountService( - createStore(settings) as never, - createRateLimits() as never, - createRuntimeHome() as never - ) - - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(fixture.config) - expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(fixture.config) - }) - it('rewrites relative path config values when syncing into managed homes', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') writeFileSync( @@ -556,7 +494,7 @@ describe('CodexAccountService config sync', () => { expect(managedConfig).toContain('sandbox_mode = "danger-full-access"') }) - it('does not rewrite managed configs that already match canonical config', async () => { + it('does not rewrite a managed config the previous mirror pass already settled', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') const { escapeTomlString } = await import('../codex/config-toml-trust') const userHookKey = `${join(testState.fakeHomeDir, '.codex', 'user-hooks.json')}:stop:0:0` @@ -575,8 +513,6 @@ describe('CodexAccountService config sync', () => { '{"account":"managed"}\n' ) const managedConfigPath = join(managedHomePath, 'config.toml') - const oldDate = new Date('2024-01-01T00:00:00.000Z') - utimesSync(managedConfigPath, oldDate, oldDate) const settings = createSettings({ codexManagedAccounts: [ { @@ -600,6 +536,15 @@ describe('CodexAccountService config sync', () => { const { CodexAccountService } = await import('./service') new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) + // The first pass remaps the user hook-trust entry into this home; once that + // has settled, a later pass must leave the file completely untouched. + const settledConfig = readFileSync(managedConfigPath, 'utf-8') + const oldDate = new Date('2024-01-01T00:00:00.000Z') + utimesSync(managedConfigPath, oldDate, oldDate) + + new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) + + expect(readFileSync(managedConfigPath, 'utf-8')).toBe(settledConfig) expect(statSync(managedConfigPath).mtimeMs).toBeLessThan(Date.now() - 60_000) }) @@ -659,8 +604,7 @@ describe('CodexAccountService config sync', () => { it('re-syncs config when selecting an account', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') - const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n' - writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') + writeFileSync(canonicalConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8') const managedHomePath = createManagedHome( testState.userDataDir, 'account-1', @@ -696,7 +640,11 @@ describe('CodexAccountService config sync', () => { await service.selectAccount('account-1') - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + // Selecting merges canonical settings into the account's own home rather + // than overwriting it, so its local approval_policy survives the re-sync. + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe( + 'sandbox_mode = "danger-full-access"\napproval_policy = "untrusted"\n' + ) expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) @@ -855,7 +803,7 @@ describe('CodexAccountService config sync', () => { readHookTrustEntries = (await import('../codex/config-toml-trust')).readHookTrustEntries writeFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), fixture.config, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings()) const rateLimits = createRateLimits() const runtimeHome = createRuntimeHome() const { CodexAccountService } = await import('./service') @@ -1842,7 +1790,7 @@ describe('CodexAccountService config sync', () => { expect(onHostSystemDefaultSelected).toHaveBeenCalledOnce() }) - it('selectAccount immediately rewrites the shared runtime auth for existing terminals', async () => { + it('selectAccount switches managed accounts without routing auth through the shared mirror', async () => { const firstAuth = createCodexAuthJson('one@example.com', 'acct-one', 'one') const secondAuth = createCodexAuthJson('two@example.com', 'acct-two', 'two') const firstManagedHomePath = createManagedHome( @@ -1890,7 +1838,7 @@ describe('CodexAccountService config sync', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const runtimeHome = new CodexRuntimeHomeService(store as never) const runtimeAuthPath = join(testState.userDataDir, 'codex-runtime-home', 'home', 'auth.json') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(firstAuth) + expect(existsSync(runtimeAuthPath)).toBe(false) const { CodexAccountService } = await import('./service') const service = new CodexAccountService( @@ -1901,7 +1849,11 @@ describe('CodexAccountService config sync', () => { await service.selectAccount('account-2') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(secondAuth) + // Each managed host account launches against its own home, so a switch must + // leave both credential files alone and never copy either into the mirror. + expect(existsSync(runtimeAuthPath)).toBe(false) + expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) expect(existsSync(join(testState.userDataDir, 'codex-runtime-home', 'launch'))).toBe(false) expect(existsSync(join(testState.userDataDir, 'codex-runtime-home', 'active'))).toBe(false) }) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 3618f138144..b55c9b50315 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -30,7 +30,6 @@ import type { CodexRuntimeHomeService } from './runtime-home-service' import { writeFileAtomically } from './fs-utils' import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { getCodexManagedHookInstallMaterial } from '../codex/hook-service' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { getSystemCodexHomePath } from '../codex/codex-home-paths' @@ -1238,9 +1237,9 @@ export class CodexAccountService { } private isSelfContainedHostManagedHome(managedHomePath: string): boolean { - // Why: flag ON makes each host account home its own launch CODEX_HOME. WSL - // homes keep their distro-local seed lane; the flag-OFF opt-out is unchanged. - return isCodexSystemDefaultRealHomeEnabled() && !parseWslUncPath(managedHomePath) + // Why: each host account home is its own launch CODEX_HOME. WSL homes keep + // their distro-local seed lane. + return !parseWslUncPath(managedHomePath) } private syncCanonicalConfigIntoManagedHome( @@ -1270,18 +1269,15 @@ export class CodexAccountService { // account while preserving consistent Codex behavior. Managed homes are // real CODEX_HOMEs for `codex login`, so relative path-valued settings // must keep resolving against the home the config was read from. - let sanitizedConfig = canonicalConfig.contents - if (isCodexSystemDefaultRealHomeEnabled()) { - const material = getCodexManagedHookInstallMaterial() - // Why: source-home Orca trust is foreign to each managed home's hooks.json. - sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { - runtimeHomePath: canonicalConfig.sourceHomePath, - sourcePath: canonicalConfig.sourceHooksPath, - command: material.command, - managedEventLabels: new Set(Object.values(material.eventLabel)), - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - }) - } + const material = getCodexManagedHookInstallMaterial() + // Why: source-home Orca trust is foreign to each managed home's hooks.json. + const sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { + runtimeHomePath: canonicalConfig.sourceHomePath, + sourcePath: canonicalConfig.sourceHooksPath, + command: material.command, + managedEventLabels: new Set(Object.values(material.eventLabel)), + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) this.writeManagedConfig( trustedManagedHomePath, rewriteRelativePathConfigValues(sanitizedConfig, canonicalConfig.sourceHomePath) diff --git a/src/main/codex/codex-real-home-flag.test.ts b/src/main/codex/codex-real-home-flag.test.ts deleted file mode 100644 index 63b6889a757..00000000000 --- a/src/main/codex/codex-real-home-flag.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { isCodexSystemDefaultRealHomeEnabled } from './codex-real-home-flag' - -const ENV_FLAG = 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME' -let previousEnvFlag: string | undefined - -beforeEach(() => { - previousEnvFlag = process.env[ENV_FLAG] - delete process.env[ENV_FLAG] -}) - -afterEach(() => { - if (previousEnvFlag === undefined) { - delete process.env[ENV_FLAG] - } else { - process.env[ENV_FLAG] = previousEnvFlag - } -}) - -describe('isCodexSystemDefaultRealHomeEnabled', () => { - it('is unconditionally ON in production (no settings consulted)', () => { - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(true) - }) - - it('lets the test-rig env override force ON explicitly', () => { - for (const raw of ['1', 'true', 'on', 'TRUE', ' On ']) { - process.env[ENV_FLAG] = raw - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(true) - } - }) - - it('lets the test-rig env override pin the legacy managed lane OFF', () => { - for (const raw of ['0', 'false', 'off']) { - process.env[ENV_FLAG] = raw - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(false) - } - }) - - it('ignores an unrecognized env value and stays ON', () => { - process.env[ENV_FLAG] = 'maybe' - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(true) - }) -}) diff --git a/src/main/codex/codex-real-home-flag.ts b/src/main/codex/codex-real-home-flag.ts deleted file mode 100644 index 541dd98efa8..00000000000 --- a/src/main/codex/codex-real-home-flag.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Routing truth for the SYSTEM-DEFAULT Codex account: it always runs against - * the user's real ~/.codex; managed (multi-account) selections always get - * their own self-contained homes. There is no user-facing setting — the - * feature ships unconditionally. - * - * The env override exists only for test rigs (containment harness, e2e home - * isolation, CDP verification) that must pin the legacy managed-home lane or - * force the real-home lane inside a disposable HOME. It never appears in any - * UI and no production path sets it. - */ -const CODEX_REAL_HOME_ENV_FLAG = 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME' - -export function isCodexSystemDefaultRealHomeEnabled(): boolean { - const envOverride = readCodexRealHomeEnvOverride() - if (envOverride !== null) { - return envOverride - } - return true -} - -function readCodexRealHomeEnvOverride(): boolean | null { - const raw = process.env[CODEX_REAL_HOME_ENV_FLAG] - if (raw === undefined) { - return null - } - const normalized = raw.trim().toLowerCase() - if (normalized === '1' || normalized === 'true' || normalized === 'on') { - return true - } - if (normalized === '0' || normalized === 'false' || normalized === 'off') { - return false - } - return null -} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 4d98e2a6690..d7655758b67 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -244,7 +244,16 @@ async function main(): Promise { onAuthenticatedClientPair: () => deathWatch.notifyClientActivity() } : {}), - spawnSubprocess: (opts) => createPtySubprocess(opts), + spawnSubprocess: (opts) => + createPtySubprocess({ + ...opts, + ...(process.platform === 'darwin' + ? { + onMacosTccSpawnStrategy: (strategy) => + daemonLog.log('macos-tcc-pty-spawn', { strategy }) + } + : {}) + }), onIdleShutdown: () => { deathWatch?.stop() shuttingDown = true diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index fdde466e9eb..9df17412c57 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -167,6 +167,7 @@ describe('createPtySubprocess', () => { it('spawns node-pty with correct options', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) + const onMacosTccSpawnStrategy = vi.fn() const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'linux' }) @@ -176,7 +177,8 @@ describe('createPtySubprocess', () => { cols: 80, rows: 24, cwd: '/home/user', - env: { SHELL: '/bin/bash', FOO: 'bar' } + env: { SHELL: '/bin/bash', FOO: 'bar' }, + onMacosTccSpawnStrategy }) } finally { if (platform) { @@ -194,6 +196,34 @@ describe('createPtySubprocess', () => { name: 'xterm-256color' }) ) + expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith('direct') + }) + + it('does not report a spawn strategy when node-pty fails before launch', () => { + spawnMock.mockImplementationOnce(() => { + throw new Error('spawn failed') + }) + const onMacosTccSpawnStrategy = vi.fn() + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + expect(() => + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { SHELL: '/bin/bash' }, + onMacosTccSpawnStrategy + }) + ).toThrow('spawn failed') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(onMacosTccSpawnStrategy).not.toHaveBeenCalled() }) it('expands variables in PATH before spawning a Windows shell', () => { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index ca8527fb11f..14fb0b5aa7b 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -126,6 +126,7 @@ export type PtySubprocessOptions = { shellOverride?: string terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void } function deleteRequestedDaemonEnvKeys( @@ -543,6 +544,7 @@ function spawnDaemonPtyWithWindowsFallback(args: { cols: number rows: number windowsFallbackAttempts: WindowsShellSpawnAttempt[] + onMacosTccSpawnStrategy?: PtySubprocessOptions['onMacosTccSpawnStrategy'] }): { process: pty.IPty shellPath: string @@ -551,7 +553,7 @@ function spawnDaemonPtyWithWindowsFallback(args: { } { const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) - return pty.spawn(wrapped.file, wrapped.args, { + const proc = pty.spawn(wrapped.file, wrapped.args, { name: args.env.TERM ?? 'xterm-256color', cols: args.cols, rows: args.rows, @@ -560,6 +562,8 @@ function spawnDaemonPtyWithWindowsFallback(args: { // Why: legacy system ConPTY can corrupt full-width TUI rows in scrollback; bundled ConPTY has the wrap-marker behavior xterm expects. ...(process.platform === 'win32' ? { useConptyDll: true } : {}) }) + args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') + return proc } try { @@ -837,7 +841,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl env, cols: size.cols, rows: size.rows, - windowsFallbackAttempts + windowsFallbackAttempts, + onMacosTccSpawnStrategy: opts.onMacosTccSpawnStrategy }) proc = spawned.process // Why: a Windows fallback (e.g. cmd.exe) carries its own argv-embedded startup command; adopt the winning shell's identity + delivery flag. diff --git a/src/main/git/git-status-read-lease-owner.ts b/src/main/git/git-status-read-lease-owner.ts index a0012da7d3a..eeae5eedf95 100644 --- a/src/main/git/git-status-read-lease-owner.ts +++ b/src/main/git/git-status-read-lease-owner.ts @@ -1,101 +1,3 @@ -type StatusReadEntry = { - controller: AbortController - promise: Promise - liveLeases: number - settled: boolean -} - -function getAbortReason(signal: AbortSignal): unknown { - try { - signal.throwIfAborted() - } catch (error) { - return error - } - return new DOMException('This operation was aborted', 'AbortError') -} - -export class GitStatusReadLeaseOwner { - private readonly entries = new Map>() - - lease( - key: string, - signal: AbortSignal | undefined, - load: (sharedSignal: AbortSignal) => Promise - ): Promise { - if (signal?.aborted) { - return Promise.reject(getAbortReason(signal)) - } - - let entry = this.entries.get(key) - if (!entry) { - const controller = new AbortController() - const promise = load(controller.signal) - const createdEntry = { controller, promise, liveLeases: 0, settled: false } - entry = createdEntry - this.entries.set(key, createdEntry) - void promise.then( - () => this.settle(key, createdEntry), - () => this.settle(key, createdEntry) - ) - } - - entry.liveLeases += 1 - return this.createLease(key, entry, signal) - } - - invalidate(): void { - this.entries.clear() - } - - private createLease( - key: string, - entry: StatusReadEntry, - signal: AbortSignal | undefined - ): Promise { - return new Promise((resolve, reject) => { - let active = true - const release = (abortReason?: unknown): boolean => { - if (!active) { - return false - } - active = false - signal?.removeEventListener('abort', onAbort) - entry.liveLeases -= 1 - if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) { - if (this.entries.get(key) === entry) { - this.entries.delete(key) - } - entry.controller.abort(abortReason) - } - return true - } - const onAbort = (): void => { - const reason = getAbortReason(signal!) - if (release(reason)) { - reject(reason) - } - } - - signal?.addEventListener('abort', onAbort, { once: true }) - void entry.promise.then( - (value) => { - if (release()) { - resolve(value) - } - }, - (error: unknown) => { - if (release()) { - reject(error) - } - } - ) - }) - } - - private settle(key: string, entry: StatusReadEntry): void { - entry.settled = true - if (this.entries.get(key) === entry) { - this.entries.delete(key) - } - } -} +// Why: the class moved to src/shared so the relay host can coalesce the same +// reads; this re-export keeps main's import path (and its tests) stable. +export { GitStatusReadLeaseOwner } from '../../shared/git-status-read-lease-owner' diff --git a/src/main/git/status-branch-line-total-exec-contract.test.ts b/src/main/git/status-branch-line-total-exec-contract.test.ts new file mode 100644 index 00000000000..13ece36daec --- /dev/null +++ b/src/main/git/status-branch-line-total-exec-contract.test.ts @@ -0,0 +1,330 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildGitBranchLineTotalDiffArgs, + invalidateGitBranchLineTotalInFlight +} from '../../shared/git-branch-line-total' + +import type * as BranchLineTotal from '../../shared/git-branch-line-total' +import type * as GitRunner from './runner' + +// Why: real git, spied argv. Every exec is recorded so the performance contract +// ("no ranged diff unless asked", "one exec for concurrent callers") can be +// asserted on the actual command list rather than on a stubbed return value. +const { gitExecCalls, execHooks, coalescerJoins } = vi.hoisted(() => ({ + gitExecCalls: [] as string[][], + execHooks: { beforeExec: undefined as undefined | ((args: string[]) => Promise | void) }, + coalescerJoins: { count: 0, onJoin: undefined as undefined | (() => void) } +})) + +vi.mock('../../shared/git-branch-line-total', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + computeGitBranchLineTotal: ( + input: Parameters[0] + ) => { + // The lease is taken synchronously inside, so counting after the call + // means a join is observable the instant it has happened. + const total = actual.computeGitBranchLineTotal(input) + coalescerJoins.count += 1 + coalescerJoins.onJoin?.() + return total + } + } +}) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + gitExecFileAsync: async (...args: Parameters) => { + gitExecCalls.push(args[0]) + await execHooks.beforeExec?.(args[0]) + return actual.gitExecFileAsync(...args) + }, + gitExecFileAsyncBuffer: async ( + ...args: Parameters + ) => { + gitExecCalls.push(args[0]) + return actual.gitExecFileAsyncBuffer(...args) + }, + gitStreamStdout: async (...args: Parameters) => { + gitExecCalls.push(args[0]) + return actual.gitStreamStdout(...args) + } + } +}) + +import { + clearEffectiveUpstreamStatusCacheForTests, + clearSubmodulePathsCacheForTests, + getStatus, + invalidateGitReadCaches +} from './status' + +const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5) +const tempRoots: string[] = [] + +function git(repo: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +async function createFixtureRepo(): Promise<{ repo: string; mergeBase: string }> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-exec-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + execFileSync('git', ['init', '-q', repo]) + git(repo, ['config', 'user.email', 'test@example.com']) + git(repo, ['config', 'user.name', 'Test User']) + git(repo, ['config', 'commit.gpgSign', 'false']) + await write(repo, 'f.txt', 'a\nb\n') + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', 'base']) + await write(repo, 'f.txt', 'a\nb\nc\n') + return { repo, mergeBase: git(repo, ['rev-parse', 'HEAD']) } +} + +async function write(repo: string, relativePath: string, contents: string): Promise { + const target = path.join(repo, relativePath) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, contents) +} + +/** The `mergeBase → worktree` diff, distinguished from the per-area numstats by its rev + `--`. */ +function rangedDiffCalls(): string[][] { + return gitExecCalls.filter((args) => args.includes('--numstat') && args.at(-1) === '--') +} + +function numstatCalls(): string[][] { + return gitExecCalls.filter((args) => args.includes('--numstat')) +} + +/** Resolves once `count` status passes have entered the branch-total coalescer. */ +function waitForCoalescerJoins(count: number): Promise { + if (coalescerJoins.count >= count) { + return Promise.resolve() + } + return new Promise((resolve) => { + coalescerJoins.onJoin = () => { + if (coalescerJoins.count >= count) { + coalescerJoins.onJoin = undefined + resolve() + } + } + }) +} + +function resetGitReadCaches(): void { + clearEffectiveUpstreamStatusCacheForTests() + clearSubmodulePathsCacheForTests() + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() +} + +beforeEach(() => { + gitExecCalls.length = 0 + execHooks.beforeExec = undefined + coalescerJoins.count = 0 + coalescerJoins.onJoin = undefined + resetGitReadCaches() +}) + +afterEach(async () => { + execHooks.beforeExec = undefined + coalescerJoins.onJoin = undefined + resetGitReadCaches() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('branch line total completeness', () => { + it('omits the total when the status listing hit its limit', async () => { + const { repo, mergeBase } = await createFixtureRepo() + await write(repo, 'u1.txt', 'a\n') + await write(repo, 'u2.txt', 'b\n') + await write(repo, 'u3.txt', 'c\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 1 }) + + expect(result.didHitLimit).toBe(true) + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it('omits the total when the ranged numstat fails, rather than reporting zero', async () => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) + + expect(result.entries.length).toBeGreaterThan(0) + expect(result.branchLineTotal).toBeUndefined() + // The diff really was attempted, so this is a failure path and not a gate. + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('omits the total for a directory that is not a git worktree', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-folder-')) + tempRoots.push(root) + + const result = await getStatus(root, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it('rejects with an AbortError when the pass is cancelled mid-diff', async () => { + const { repo, mergeBase } = await createFixtureRepo() + const controller = new AbortController() + execHooks.beforeExec = (args) => { + if (args.includes('--numstat') && args.at(-1) === '--') { + controller.abort() + } + } + + await expect( + getStatus(repo, { branchLineTotalMergeBase: mergeBase, signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + // The pass had already streamed status and reached the ranged diff, so this + // is a cancellation in flight rather than a pre-flight rejection. + expect(rangedDiffCalls()).toHaveLength(1) + }) +}) + +describe('branch line total merge-base gate', () => { + it('runs no ranged diff and returns no total without a merge base', async () => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it.each(['--upload-pack=x', 'HEAD', '', 'origin/main', '-M', 'A1B2C3D'])( + 'never spawns a ranged diff for a merge base of %j', + async (branchLineTotalMergeBase) => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo, { branchLineTotalMergeBase }) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + expect(gitExecCalls).not.toContainEqual( + buildGitBranchLineTotalDiffArgs(branchLineTotalMergeBase) + ) + } + ) + + it('never leaks a flag-shaped merge base into any git argv', async () => { + const { repo } = await createFixtureRepo() + + await getStatus(repo, { branchLineTotalMergeBase: '--upload-pack=/tmp/evil' }) + + for (const args of gitExecCalls) { + expect(args).not.toContain('--upload-pack=/tmp/evil') + } + }) +}) + +describe('branch line total exec budget', () => { + it('costs exactly one extra exec versus the same pass with the chip hidden', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + await getStatus(repo) + const withoutChip = gitExecCalls.map((args) => args.join(' ')) + + gitExecCalls.length = 0 + resetGitReadCaches() + await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + const withChip = gitExecCalls.map((args) => args.join(' ')) + + expect(withChip.filter((args) => !args.endsWith(`${mergeBase} --`))).toEqual(withoutChip) + expect(withChip).toHaveLength(withoutChip.length + 1) + }) + + it('shares one ranged diff between two concurrent identical status passes', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + const [first, second] = await Promise.all([ + getStatus(repo, { branchLineTotalMergeBase: mergeBase }), + getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + ]) + + expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('shares one ranged diff across status passes the status lease cannot merge', async () => { + const { repo, mergeBase } = await createFixtureRepo() + // Why: `limit` is part of the status read key, so these two passes each run + // their own `git status`; only the branch-total coalescer can merge the diff. + // Hold the first diff exactly until the second pass has taken the lease: a + // fixed sleep would either release too early or, on a slow machine, outrun + // GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS and publish without an exact total. + execHooks.beforeExec = async (args) => { + if (args.includes('--numstat') && args.at(-1) === '--') { + await waitForCoalescerJoins(2) + } + } + + const [first, second] = await Promise.all([ + getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 0 }), + getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 }) + ]) + + expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + // Proves the saving came from the branch-total coalescer: both passes really + // ran their own `git status`, so the status lease merged nothing. + expect(gitExecCalls.filter((args) => args.includes('status'))).toHaveLength(2) + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('reuses the cached total when the pass reuses cached line stats', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + const first = await getStatus(repo, { + branchLineTotalMergeBase: mergeBase, + reuseLineStats: true + }) + expect(rangedDiffCalls()).toHaveLength(1) + expect(numstatCalls()).toHaveLength(2) + + gitExecCalls.length = 0 + invalidateGitBranchLineTotalInFlight() + const second = await getStatus(repo, { + branchLineTotalMergeBase: mergeBase, + reuseLineStats: true + }) + + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + expect(second.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(numstatCalls()).toEqual([]) + }) + + it('recomputes rather than reusing a total measured against another merge base', async () => { + const { repo, mergeBase } = await createFixtureRepo() + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', 'second']) + const laterMergeBase = git(repo, ['rev-parse', 'HEAD']) + + await getStatus(repo, { branchLineTotalMergeBase: mergeBase, reuseLineStats: true }) + gitExecCalls.length = 0 + invalidateGitBranchLineTotalInFlight() + const result = await getStatus(repo, { + branchLineTotalMergeBase: laterMergeBase, + reuseLineStats: true + }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase: laterMergeBase }) + expect(rangedDiffCalls()).toHaveLength(1) + }) +}) diff --git a/src/main/git/status-branch-line-total-real-git.test.ts b/src/main/git/status-branch-line-total-real-git.test.ts new file mode 100644 index 00000000000..80426c76e65 --- /dev/null +++ b/src/main/git/status-branch-line-total-real-git.test.ts @@ -0,0 +1,203 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' +import { getStatus, invalidateGitReadCaches } from './status' + +const tempRoots: string[] = [] + +function git(repo: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +async function createFixtureRepo(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + execFileSync('git', ['init', '-q', repo]) + git(repo, ['config', 'user.email', 'test@example.com']) + git(repo, ['config', 'user.name', 'Test User']) + git(repo, ['config', 'commit.gpgSign', 'false']) + return repo +} + +async function write(repo: string, relativePath: string, contents: string | Buffer): Promise { + const target = path.join(repo, relativePath) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, contents) +} + +/** The forked-from commit the chip measures against, as branch compare would resolve it. */ +function commitAll(repo: string, message: string): string { + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', message]) + return git(repo, ['rev-parse', 'HEAD']) +} + +/** What a plain `git diff ` reports, i.e. the number the chip must not disagree with. */ +function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const line of git(repo, ['diff', '--numstat', '-M', mergeBase, '--']).split(/\r?\n/)) { + if (!line) { + continue + } + const [rawAdded, rawRemoved] = line.split('\t') + added += rawAdded === '-' ? 0 : Number.parseInt(rawAdded ?? '0', 10) + removed += rawRemoved === '-' ? 0 : Number.parseInt(rawRemoved ?? '0', 10) + } + return { added, removed } +} + +beforeEach(() => { + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() +}) + +afterEach(async () => { + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('branch line total against a real repository', () => { + it('measures a partially staged line once, not once per status area', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'line1\n') + const mergeBase = commitAll(repo, 'base') + // Stage "+foo", then edit that same line to "bar" without staging it: the + // per-area rows read +1/-0 staged and +1/-1 unstaged, summing to +2/-1. + await write(repo, 'f.txt', 'line1\nfoo\n') + git(repo, ['add', 'f.txt']) + await write(repo, 'f.txt', 'line1\nbar\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(result.entries.map((entry) => [entry.area, entry.added, entry.removed])).toEqual([ + ['staged', 1, 0], + ['unstaged', 1, 1] + ]) + }) + + it('nets a line added in a branch commit and deleted in the working tree to zero', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\n') + commitAll(repo, 'add b') + await write(repo, 'f.txt', 'a\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('keeps the total across a commit and updates it on the next status call after an edit', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\nc\n') + + const beforeCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(beforeCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + + commitAll(repo, 'commit the edit') + const afterCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(afterCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + + await write(repo, 'f.txt', 'a\nb\nc\nd\n') + const afterSecondEdit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(afterSecondEdit.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + }) + + it('counts a rename across the commit boundary once, using the post-rename path', async () => { + const repo = await createFixtureRepo() + await write(repo, 'old.txt', 'a\nb\nc\nd\ne\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['mv', 'old.txt', 'new.txt']) + commitAll(repo, 'rename') + await write(repo, 'new.txt', 'a\nb\nc\nd\ne\nf\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + }) + + it('counts an untracked-only branch from the untracked file contents', async () => { + const repo = await createFixtureRepo() + await write(repo, 'kept.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'src/new.ts', 'one\ntwo\nthree\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.entries.map((entry) => entry.area)).toEqual(['untracked']) + expect(result.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + }) + + it('excludes a binary-only change, matching numstat reporting it as "-"', async () => { + const repo = await createFixtureRepo() + await write(repo, 'blob.bin', Buffer.from([0, 1, 2, 3, 10, 65, 66])) + const mergeBase = commitAll(repo, 'base') + await write(repo, 'blob.bin', Buffer.from([0, 9, 10, 11, 12, 67, 68, 69, 70])) + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/) + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('contributes nothing for a pure rename', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\nb\nc\nd\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['mv', 'f.txt', 'g.txt']) + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('includes untracked additions alongside tracked range changes', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\nb\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\nc\n') + commitAll(repo, 'append c') + await write(repo, 'untracked.txt', 'u1\nu2\nu3\nu4\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 5, removed: 0, mergeBase }) + }) + + // Documented deviation from a pure `git diff`: the untracked half is added on + // top of the tracked range, so a deleted-then-recreated path is counted twice. + // Locked in deliberately — changing it must be a deliberate spec change. + it('counts both the deletion and the recreated untracked file for the same path', async () => { + const repo = await createFixtureRepo() + await write(repo, 'gone.txt', 'a\nb\nc\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['rm', '-q', 'gone.txt']) + commitAll(repo, 'delete gone.txt') + await write(repo, 'gone.txt', 'n1\nn2\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.entries.map((entry) => [entry.area, entry.path])).toEqual([ + ['untracked', 'gone.txt'] + ]) + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 }) + expect(result.branchLineTotal).toEqual({ added: 2, removed: 3, mergeBase }) + }) +}) diff --git a/src/main/git/status-branch-line-total-relay-parity.test.ts b/src/main/git/status-branch-line-total-relay-parity.test.ts new file mode 100644 index 00000000000..e9f5240728c --- /dev/null +++ b/src/main/git/status-branch-line-total-relay-parity.test.ts @@ -0,0 +1,172 @@ +/** + * Main and relay must publish the same `branchLineTotal` for the same fixture + * repo. Both call sites + * share `src/shared/git-branch-line-total.ts`; this is the test that catches one + * of them wiring it up differently — different flags, a different untracked + * source, a different completeness gate. + */ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getStatus } from './status' +import type { GitExec } from '../../relay/git-handler-ops' +import { getStatusOp } from '../../relay/git-handler-status-ops' +import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' +import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache' + +const execFileAsync = promisify(execFile) + +// Fork point → working tree for the fixture below: +// tracked.txt +2 (branch commit only) +// partial.txt +1 (staged +2 then one of those lines removed unstaged) +// flip.txt 0 (added in the branch commit, removed again in the worktree) +// moved.txt 0 (pure rename) +// fresh.txt +3 (untracked) +const EXPECTED_TOTAL = { added: 6, removed: 0 } +// Summing the per-area status rows instead would give this — the wrong answer +// the shared module exists to avoid. +const AREA_ROW_SUM = { added: 5, removed: 2 } + +const relayGit: GitExec = async (args, cwd, opts) => { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.timeout ? { timeout: opts.timeout } : {}) + }) + return { stdout, stderr } +} + +const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => { + const { stdout } = await relayGit(args, cwd, { + disableOptionalLocks: options.disableOptionalLocks, + signal: options.signal + }) + return { stoppedEarly: options.onStdout(stdout) === true } +} + +function runFixtureGit(repo: string, args: string[]): string { + return execFileSync( + 'git', + [ + '-c', + 'user.email=test@test.com', + '-c', + 'user.name=Test', + '-c', + 'commit.gpgSign=false', + ...args + ], + { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +/** Returns the merge-base OID the chip is measured against. */ +async function seedParityFixture(repo: string): Promise { + execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) + await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n') + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n') + await writeFile(path.join(repo, 'renamed.txt'), 'stable\n') + await writeFile(path.join(repo, 'flip.txt'), 'p\n') + runFixtureGit(repo, ['add', '.']) + runFixtureGit(repo, ['commit', '-m', 'base']) + const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) + + runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) + await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n') + await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n') + runFixtureGit(repo, ['add', '-A']) + runFixtureGit(repo, ['commit', '-m', 'branch commit']) + + runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt']) + // Staged and unstaged hunks land on the same added lines, so an area sum + // double-counts them. + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n') + runFixtureGit(repo, ['add', 'partial.txt']) + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n') + // The branch commit's line, taken back out in the worktree: net zero. + await writeFile(path.join(repo, 'flip.txt'), 'p\n') + await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n') + return mergeBase +} + +function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): { + added: number + removed: number +} { + let added = 0 + let removed = 0 + for (const entry of entries) { + added += entry.added ?? 0 + removed += entry.removed ?? 0 + } + return { added, removed } +} + +describe('branch line total parity between main and relay', () => { + let repo: string + + beforeEach(async () => { + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-')) + }) + + afterEach(async () => { + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + await rm(repo, { recursive: true, force: true }) + }) + + it('produces identical totals for the same fixture repo', async () => { + const mergeBase = await seedParityFixture(repo) + + const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { + worktreePath: repo, + branchLineTotalMergeBase: mergeBase + }) + + expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase }) + expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) + // Both sides model the same worktree, so a parity pass on a mismatched + // entry list would be meaningless. + expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual( + mainStatus.entries.map((entry) => entry.path).sort() + ) + }) + + it('agrees on a number no per-area row sum could produce', async () => { + const mergeBase = await seedParityFixture(repo) + + const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { + worktreePath: repo, + branchLineTotalMergeBase: mergeBase + }) + + expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM) + expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual( + AREA_ROW_SUM + ) + expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM) + expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) + }) + + it('omits the total on both sides when no merge base is requested', async () => { + await seedParityFixture(repo) + + const mainStatus = await getStatus(repo) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo }) + + expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false) + expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false) + }) +}) diff --git a/src/main/git/status-upstream-ref.test.ts b/src/main/git/status-upstream-ref.test.ts new file mode 100644 index 00000000000..7d8af55764e --- /dev/null +++ b/src/main/git/status-upstream-ref.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveGitStatusUpstreamRef } from './status-upstream-ref' + +const signal = (): AbortSignal => new AbortController().signal + +describe('resolveGitStatusUpstreamRef', () => { + it('preserves the exact namespace for a slash-containing remote name', async () => { + const execGit = vi.fn().mockResolvedValue({ + stdout: + 'refs/heads/feature/main\0refs/remotes/team/fork/feature/main\0team/fork/feature/main\n' + }) + + await expect( + resolveGitStatusUpstreamRef( + execGit, + '/repo', + 'refs/heads/feature/main', + 'team/fork/feature/main', + signal() + ) + ).resolves.toBe('refs/remotes/team/fork/feature/main') + expect(execGit).toHaveBeenCalledWith( + [ + 'for-each-ref', + '--format=%(refname)%00%(upstream)%00%(upstream:short)', + '--count=1', + 'refs/heads/feature/main' + ], + '/repo', + expect.any(AbortSignal) + ) + }) + + it('returns a slash-named local upstream for the binding validator to reject', async () => { + const execGit = vi.fn().mockResolvedValue({ + stdout: 'refs/heads/feature/topic\0refs/heads/feature/base\0feature/base\n' + }) + + await expect( + resolveGitStatusUpstreamRef( + execGit, + '/repo', + 'refs/heads/feature/topic', + 'feature/base', + signal() + ) + ).resolves.toBe('refs/heads/feature/base') + }) + + it('returns a missing custom refspec destination from branch configuration', async () => { + const execGit = vi.fn().mockResolvedValue({ + stdout: 'refs/heads/feature/main\0refs/custom/origin/main\0custom/origin/main\n' + }) + + await expect( + resolveGitStatusUpstreamRef( + execGit, + '/repo', + 'refs/heads/feature/main', + 'custom/origin/main', + signal() + ) + ).resolves.toBe('refs/custom/origin/main') + expect(execGit).toHaveBeenCalledOnce() + }) + + it('resolves an accepted effective upstream override explicitly', async () => { + const execGit = vi + .fn() + .mockResolvedValueOnce({ + stdout: 'refs/heads/feature/prompts\0refs/remotes/origin/main\0origin/main\n' + }) + .mockResolvedValueOnce({ + stdout: '--end-of-options\nrefs/remotes/origin/feature/prompts\n' + }) + + await expect( + resolveGitStatusUpstreamRef( + execGit, + '/repo', + 'refs/heads/feature/prompts', + 'origin/feature/prompts', + signal() + ) + ).resolves.toBe('refs/remotes/origin/feature/prompts') + expect(execGit).toHaveBeenNthCalledWith( + 2, + ['rev-parse', '--symbolic-full-name', '--end-of-options', 'origin/feature/prompts'], + '/repo', + expect.any(AbortSignal) + ) + }) + + it('fails safe when an effective override is ambiguous', async () => { + const execGit = vi + .fn() + .mockResolvedValueOnce({ + stdout: 'refs/heads/feature/main\0refs/remotes/origin/main\0origin/main\n' + }) + .mockResolvedValueOnce({ + stdout: 'refs/heads/origin/feature/main\nrefs/remotes/origin/feature/main\n' + }) + + await expect( + resolveGitStatusUpstreamRef( + execGit, + '/repo', + 'refs/heads/feature/main', + 'origin/feature/main', + signal() + ) + ).resolves.toBeUndefined() + }) + + it('rejects a stale status whose accepted branch no longer exists', async () => { + const execGit = vi.fn().mockResolvedValue({ stdout: '' }) + + await expect( + resolveGitStatusUpstreamRef(execGit, '/repo', 'refs/heads/old', 'origin/old', signal()) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/git/status-upstream-ref.ts b/src/main/git/status-upstream-ref.ts new file mode 100644 index 00000000000..3ad5237d312 --- /dev/null +++ b/src/main/git/status-upstream-ref.ts @@ -0,0 +1,52 @@ +import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' + +type GitStatusUpstreamRefExec = ( + args: string[], + cwd: string, + signal: AbortSignal +) => Promise<{ stdout: string }> + +function exactRefFromOutput(stdout: string): string | undefined { + const refs = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(isSafeGitRefName) + return refs.length === 1 ? refs[0] : undefined +} + +export async function resolveGitStatusUpstreamRef( + execGit: GitStatusUpstreamRefExec, + worktreePath: string, + branch: string, + upstreamName: string, + signal: AbortSignal +): Promise { + if (!branch.startsWith('refs/heads/') || !isSafeGitRefName(branch)) { + return undefined + } + const result = await execGit( + ['for-each-ref', '--format=%(refname)%00%(upstream)%00%(upstream:short)', '--count=1', branch], + worktreePath, + signal + ) + const fields = result.stdout.replace(/\r?\n$/, '').split('\0') + if (fields.length !== 3 || fields[0] !== branch) { + return undefined + } + if (fields[2] === upstreamName) { + return exactRefFromOutput(fields[1]) + } + try { + const effective = await execGit( + ['rev-parse', '--symbolic-full-name', '--end-of-options', upstreamName], + worktreePath, + signal + ) + return exactRefFromOutput(effective.stdout) + } catch { + if (signal.aborted) { + throw signal.reason + } + return undefined + } +} diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 120c84869d2..c03d9307112 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -54,6 +54,13 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' +import { + computeGitBranchLineTotal, + invalidateGitBranchLineTotalInFlight, + readGitBranchLineTotalMergeBaseParam, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../../shared/git-branch-line-total' import { beginGitStatusLineStatsCacheWrite, clearGitStatusLineStatsCache, @@ -95,10 +102,12 @@ const gitDiffReadDedupe = new InFlightPromiseDedupe() const effectiveUpstreamStatusWriteGeneration = new Map() const statusReadLeaseOwner = new GitStatusReadLeaseOwner() -// Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read. +// Why: clear every in-flight git read cache; clearing only some would let a post-mutation +// getStatus() join a pre-mutation read and publish it as current. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadLeaseOwner.invalidate() + invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() @@ -195,6 +204,9 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number { export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean reuseLineStats?: boolean + /** Merge-base OID the caller wants the branch line total measured against; + * omitted means the chip is hidden, so no ranged diff runs at all. */ + branchLineTotalMergeBase?: string /** * Max changed-file entries before git is stopped and the result is marked * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. @@ -232,6 +244,9 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri options.wslDistro ?? '', options.includeIgnored === true, options.reuseLineStats === true, + // Why: the result carries a total only for callers who asked, and only for + // this fork point, so a shared lease must never serve one to the other. + options.branchLineTotalMergeBase ?? '', options.bypassEffectiveUpstreamNegativeCache === true, limit, // Why: this changes which entries survive, so it must not share a cache slot. @@ -371,16 +386,25 @@ async function runGetStatus( } // Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set. + let branchLineTotal: GitBranchLineTotal | undefined if (!didHitLimit) { - await reuseOrRecomputeGitStatusLineStats({ + const branchLineTotalInput = createBranchLineTotalInput( + worktreePath, + entries, + options, + statusSucceeded + ) + const lineStats = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: options.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(worktreePath, entries, options) + recompute: () => attachLineStats(worktreePath, entries, options), + ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) }) + branchLineTotal = lineStats.branchLineTotal } else { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } @@ -398,6 +422,7 @@ async function runGetStatus( head, branch, ...(options.includeIgnored ? { ignoredPaths: parser.ignoredPaths } : {}), + ...(branchLineTotal ? { branchLineTotal } : {}), ...(didHitLimit ? { didHitLimit: true, statusLength: parser.statusLength } : {}), ...(statusSucceeded ? { @@ -416,6 +441,43 @@ async function runGetStatus( } } +/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */ +function createBranchLineTotalInput( + worktreePath: string, + entries: GitStatusEntry[], + options: GetStatusOptions, + statusSucceeded: boolean +): { mergeBase: string; compute: () => Promise } | undefined { + const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase) + // Why: a failed status scan leaves the untracked list untrustworthy, so the + // total would silently under-count rather than be absent. + if (mergeBase === undefined || !statusSucceeded) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Why: the same path can be a different filesystem per WSL distro. + hostKey: options.wslDistro ?? 'native', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path), + runDiffNumstat: (args, signal) => + gitExecFileAsync(args, { + ...gitOptionsForWorktree(worktreePath, options), + // Why: after the spread, so the shared lease signal wins over this caller's own. + signal, + env: gitOptionalLocksDisabledEnv(), + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then((result) => result.stdout), + ...(options.signal ? { signal: options.signal } : {}) + }) + } +} + function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host. return `${options.wslDistro ?? 'native'}\0${worktreePath}` diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 88d61c1bcc9..e9b8a10282c 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -111,11 +111,15 @@ describe('issue source operations', () => { title: 'Use upstream issues', state: 'open', html_url: 'https://github.com/stablyai/orca/issues/923', - labels: [] + labels: [], + body: 'The issue body' }) }) - await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 }) + await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ + number: 923, + description: 'The issue body' + }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '--cache', '300s', 'repos/stablyai/orca/issues/923'], { cwd: '/repo-root', host: 'github.com' } diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 45ed0b1a99e..3ffa6dc662a 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -87,7 +87,7 @@ export async function getIssue( } // Fallback for non-GitHub remotes const { stdout } = await ghExecFileAsync( - ['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'], + ['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels,body'], ghOptions ) const data = JSON.parse(stdout) diff --git a/src/main/github/mappers.ts b/src/main/github/mappers.ts index 888b02ea527..11ce858cdfb 100644 --- a/src/main/github/mappers.ts +++ b/src/main/github/mappers.ts @@ -133,13 +133,15 @@ export function mapIssueInfo(data: { url?: string html_url?: string labels?: { name: string }[] + body?: string | null }): IssueInfo { return { number: data.number, title: data.title, state: data.state?.toLowerCase() === 'open' ? 'open' : 'closed', url: data.html_url ?? data.url ?? '', - labels: (data.labels || []).map((l) => l.name) + labels: (data.labels || []).map((l) => l.name), + ...(typeof data.body === 'string' ? { description: data.body } : {}) } } diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 02810726347..069b3b954be 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -16,12 +18,86 @@ vi.mock('os', async () => { }) import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service' +import { buildWindowsGrokHookScript } from './windows-grok-hook-script' import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ +type WindowsGrokHookRun = { + status: number | null + stderr: string + stdout: string + request?: { path: string; body: string } +} + +function createWindowsGrokHookEnvironment(grokHome?: string): NodeJS.ProcessEnv { + const env = { ...process.env } as NodeJS.ProcessEnv + delete env.ORCA_AGENT_HOOK_ENDPOINT + if (grokHome === undefined) { + delete env.GROK_HOME + } else { + env.GROK_HOME = grokHome + } + env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + env.ORCA_PANE_KEY = 'pane-test' + return env +} + +async function runWindowsGrokHook( + scriptPath: string, + env: NodeJS.ProcessEnv, + input: string +): Promise { + let request: WindowsGrokHookRun['request'] + const server = createServer((incoming, response) => { + let body = '' + incoming.setEncoding('utf8') + incoming.on('data', (chunk: string) => { + body += chunk + }) + incoming.on('end', () => { + request = { path: incoming.url ?? '', body } + response.writeHead(204).end() + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Could not resolve Windows Grok hook test listener port') + } + env.ORCA_AGENT_HOOK_PORT = String(address.port) + try { + const result = await new Promise>((resolve, reject) => { + const child = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', scriptPath], { + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let stderr = '' + let stdout = '' + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk + }) + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + }) + child.once('error', reject) + child.once('close', (status) => resolve({ status, stderr, stdout })) + child.stdin.end(input) + }) + return { ...result, request } + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + describe('GrokHookService', () => { let homeDir: string @@ -35,6 +111,108 @@ describe('GrokHookService', () => { rmSync(homeDir, { recursive: true, force: true }) }) + // Why: #9358 / #9941 — empty GROK_HOME + parse-time %VAR:~n,m% / `"\"` broke + // every SessionStart/UserPromptSubmit on Windows outside Orca terminals. + it('guards Windows GROK_HOME substring checks when empty (#9358)', () => { + const script = buildWindowsGrokHookScript() + expect(script).toContain('set "ORCA_GROK_HOME="') + expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('%GROK_HOME:~4096,1%') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') + expect(script).toContain(':orca_grok_home_ready') + expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"') + expect(script).toContain('if not "%GROK_HOME:~4096,1%"=="" goto :orca_grok_home_ready') + // Why: parenthesized `if defined (...)` still parse-expands the body early. + expect(script).not.toMatch(/if defined GROK_HOME \(/) + }) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 when GROK_HOME is unset (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-unset.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + // Why: delete GROK_HOME rather than set '' so cmd sees "not defined". + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(), + '{"hook_event_name":"SessionStart"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"SessionStart"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 with trailing-backslash GROK_HOME (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-slash.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const trailing = `${join(homeDir, 'grok-home-with-slash')}\\` + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(trailing), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe(`${trailing}.`) + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 with an oversized GROK_HOME (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-oversized.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(`C:\\${'a'.repeat(9000)}`), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'omits a max-length trailing-backslash GROK_HOME after safe normalization (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-max-trailing.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const trailingAtLimit = `C:\\${'a'.repeat(4092)}\\` + expect(trailingAtLimit).toHaveLength(4096) + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(trailingAtLimit), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + it('installs a dedicated global Grok hook config and managed script', () => { const status = new GrokHookService().install() @@ -89,11 +267,12 @@ describe('GrokHookService', () => { expect(script).toContain('/hook/grok') if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') - expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') expect(script).toContain('%GROK_HOME:~4096,1%') - expect(script).toContain( - 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."' - ) + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') + expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"') expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"') } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 564565d0d24..51a6150eab6 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -5,7 +5,6 @@ import { resolveGrokHomeDir } from '../../shared/grok-session-paths' import { buildManagedCommandHook, createManagedCommandMatcher, - buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, @@ -20,19 +19,17 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' import { - buildPosixHookPayloadCapture, - buildWindowsHookEnvironmentGuardLines, - buildWindowsHookStdinDrainEpilogue -} from '../agent-hooks/hook-stdin-contract' + buildWindowsGrokHookScript, + GROK_HOME_ENVELOPE_MAX_LENGTH +} from './windows-grok-hook-script' // Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare // `*` is not a valid "match all" pattern and can fail to load/match, so tool // lifecycle hooks never fire. `.*` matches every tool name (same as Command // Code's managed hooks). const GROK_TOOL_EVENT_MATCHER = '.*' -const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 -const WINDOWS_HOOK_PAYLOAD_FORM_LINE = ' --data-urlencode "payload@-" >nul 2>nul' const GROK_EVENTS = [ { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, @@ -95,11 +92,6 @@ function hasControlCharacter(value: string): boolean { }) } -const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace( - WINDOWS_HOOK_PAYLOAD_FORM_LINE, - ` --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}` -) - function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' } @@ -116,21 +108,7 @@ function getManagedCommand(scriptPath: string): string { function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { - return [ - '@echo off', - 'setlocal', - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - ...buildWindowsHookEnvironmentGuardLines(), - 'set "ORCA_GROK_HOME=%GROK_HOME%"', - `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, - // Why: a trailing backslash escapes curl's closing argv quote on Windows, - // merging the payload option into grokHome and dropping the hook body. - 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', - WINDOWS_GROK_HOOK_POST_COMMAND, - 'exit /b 0', - ...buildWindowsHookStdinDrainEpilogue(), - '' - ].join('\r\n') + return buildWindowsGrokHookScript() } return [ diff --git a/src/main/grok/windows-grok-hook-script.ts b/src/main/grok/windows-grok-hook-script.ts new file mode 100644 index 00000000000..fb396dfafbc --- /dev/null +++ b/src/main/grok/windows-grok-hook-script.ts @@ -0,0 +1,45 @@ +import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' +import { + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' + +/** Matches the envelope length cap used by the POSIX grok-hook branch. */ +export const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 + +const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok', [ + // Why: attach grokHome before payload@- without string-replacing the shared template. + ' --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^' +]) + +/** + * Windows `grok-hook.cmd` body. + * + * Why (#9358 / #9941): cmd expands `%VAR:~n,m%` at parse time. When `GROK_HOME` + * is unset (the default outside an Orca-managed terminal), length/trailing + * guards become a syntax error and every Grok hook event fails with exit 255. + * + * - Guard substring ops behind `if defined` + goto (not a parenthesized block). + * - Reject oversized values before copying them onto a cmd input line. + */ +export function buildWindowsGrokHookScript(): string { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + 'set "ORCA_GROK_HOME="', + 'if not defined GROK_HOME goto :orca_grok_home_ready', + `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" goto :orca_grok_home_ready`, + 'set "ORCA_GROK_HOME=%GROK_HOME%"', + 'if not defined ORCA_GROK_HOME goto :orca_grok_home_ready', + 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', + // Why: the trailing-backslash safety sentinel counts toward the relay envelope. + `if not "%ORCA_GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, + ':orca_grok_home_ready', + WINDOWS_GROK_HOOK_POST_COMMAND, + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') +} diff --git a/src/main/index.ts b/src/main/index.ts index 3ea7a53a184..61c7d799f94 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -174,7 +174,10 @@ import { } from './startup/startup-diagnostics' import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' -import { createServeDesktopActivationGate } from './startup/serve-desktop-activation' +import { + createServeDesktopActivationGate, + settleServeDesktopActivation as settleServeDesktopActivationGate +} from './startup/serve-desktop-activation' import { RateLimitService } from './rate-limits/service' import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' @@ -255,7 +258,7 @@ import { AgentAwakeService } from './agent-awake-service' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { settleTeardownWithinDeadline } from './quit-teardown-deadline' import { quitTeardownStartGate } from './quit-teardown-start-gate' -import { detachAllSshSessionsForShutdown } from './ipc/ssh' +import { beginSshShutdown } from './ipc/ssh' import { PluginService } from './plugins/plugin-service' import { PluginKillListService } from './plugins/plugin-kill-list-service' import { getPluginsDataDir } from './plugins/plugin-discovery' @@ -630,11 +633,9 @@ function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { } function settleServeDesktopActivation(): void { - if (getLocalPtyProvider() instanceof LocalPtyProvider) { - desktopActivationGate.markBlocked('persistent PTY provider unavailable') - return - } - desktopActivationGate.markReady() + settleServeDesktopActivationGate(desktopActivationGate, { + hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) + }) } // Why: webContents-scoped auto-expiring flag so an intent can't leak to a later renderer load; `consume` clears on match for one-shot signals. @@ -2983,8 +2984,6 @@ app.on('before-quit', () => { isQuitting = true desktopRelayService?.fenceAndCloseNow() runtimeRpc?.setMobileRelayPairingProvider(null) - unsubscribeSystemResumeBroadcast?.() - unsubscribeSystemResumeBroadcast = null unsubscribeAgentAwakeStatusChanges?.() unsubscribeAgentAwakeStatusChanges = null agentAwakeService?.dispose() @@ -3010,6 +3009,8 @@ app.on('will-quit', (e) => { if (!quitTeardownStartGate.tryStart(e)) { return } + unsubscribeSystemResumeBroadcast?.() + unsubscribeSystemResumeBroadcast = null // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. stopTccPromptNotice() const updateQuitInProgress = isQuittingForUpdate() @@ -3046,7 +3047,9 @@ app.on('will-quit', (e) => { runtime?.getOffscreenBrowserBackend()?.destroyAll?.() browserManager.setBrowserGuestStateChangedListener(null) const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - const sshShutdown = detachAllSshSessionsForShutdown() + // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every + // active SSH lease detached in memory synchronously, and that flush is what persists it. + const sshShutdown = beginSshShutdown() killAllPty() const watcherShutdown = shutdownWatchersOnce() const storeFlush = store?.flushAsync() ?? Promise.resolve() diff --git a/src/main/ipc/browser-session-profile-ipc.test.ts b/src/main/ipc/browser-session-profile-ipc.test.ts new file mode 100644 index 00000000000..046255d6267 --- /dev/null +++ b/src/main/ipc/browser-session-profile-ipc.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, removeHandlerMock, createProfileMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + createProfileMock: vi.fn() +})) + +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents: vi.fn() }, + ipcMain: { handle: handleMock, removeHandler: removeHandlerMock }, + webContents: { fromId: vi.fn() } +})) + +vi.mock('../browser/browser-manager', () => ({ + browserCertificateTrustController: { proceed: vi.fn() }, + browserManager: { + getWebContentsIdByTabId: vi.fn(() => new Map()) + } +})) + +vi.mock('../browser/browser-session-registry', () => ({ + browserSessionRegistry: { + createProfile: createProfileMock + } +})) + +vi.mock('../browser/browser-cookie-import', () => ({ + detectInstalledBrowsers: vi.fn(() => []), + importCookiesFromBrowser: vi.fn(), + importCookiesFromFile: vi.fn(), + pickCookieFile: vi.fn(), + selectBrowserProfile: vi.fn() +})) + +import { registerBrowserHandlers, setTrustedBrowserRendererWebContentsId } from './browser' + +describe('browser session profile IPC', () => { + beforeEach(() => { + handleMock.mockReset() + removeHandlerMock.mockReset() + createProfileMock.mockReset() + setTrustedBrowserRendererWebContentsId(null) + }) + + it('forwards the user-agent mode from a trusted renderer', () => { + const profile = { + id: 'profile-google', + scope: 'isolated', + partition: 'persist:orca-browser-session-profile-google', + label: 'Google', + source: null, + userAgentMode: 'native' + } + createProfileMock.mockReturnValue(profile) + registerBrowserHandlers() + const createHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:session:createProfile' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { scope: 'isolated'; label: string; userAgentMode: 'native' } + ) => unknown + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + expect( + createHandler({ sender }, { scope: 'isolated', label: 'Google', userAgentMode: 'native' }) + ).toEqual(profile) + expect(createProfileMock).toHaveBeenCalledWith('isolated', 'Google', { + userAgentMode: 'native' + }) + }) +}) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 504b4108948..3d2800040ea 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -26,6 +26,7 @@ import type { BrowserCookieImportResult, BrowserCertificateProceedResult, BrowserSessionProfile, + BrowserSessionProfileCreateOptions, BrowserSessionProfileScope, BrowserViewportOverride } from '../../shared/types' @@ -255,8 +256,10 @@ export function registerBrowserHandlers(): void { // with a new webContentsId. The bridge must destroy the old session's // proxy (its webContents is gone) and let the next command recreate it. const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId) + const profile = browserSessionRegistry.getProfile(args.sessionProfileId ?? 'default') const registered = browserManager.registerGuest({ ...args, + userAgentMode: profile?.userAgentMode, rendererWebContentsId: event.sender.id }) if (!registered) { @@ -584,12 +587,17 @@ export function registerBrowserHandlers(): void { 'browser:session:createProfile', ( event, - args: { scope: BrowserSessionProfileScope; label: string } + args: { + scope: BrowserSessionProfileScope + label: string + } & BrowserSessionProfileCreateOptions ): BrowserSessionProfile | null => { if (!isTrustedBrowserRenderer(event.sender)) { return null } - return browserSessionRegistry.createProfile(args.scope, args.label) + return browserSessionRegistry.createProfile(args.scope, args.label, { + userAgentMode: args.userAgentMode + }) } ) diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index 1fb1bb12d7b..f291580db40 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -238,23 +238,41 @@ describe('renderer breadcrumb IPC routing', () => { // Why: park-churn notices are per-tab but the ring is process-wide, so many // tabs churning at once would otherwise evict the pre-crash trail. - it('coalesces park-verdict churn notices by name across tabs', () => { + it('coalesces park-verdict churn notices by trigger across tabs', () => { emitRendererBreadcrumb({ name: 'terminal_park_verdict_churn', - data: { tabId: 'tab-1', flips: 12, elapsedMs: 8 } + data: { tabId: 'tab-1', trigger: 'window', flips: 12, elapsedMs: 8 } }) emitRendererBreadcrumb({ name: 'terminal_park_verdict_churn', - data: { tabId: 'tab-2', flips: 12, elapsedMs: 9 } + data: { tabId: 'tab-2', trigger: 'window', flips: 12, elapsedMs: 9 } }) expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledTimes(2) for (const call of recordCoalescedCrashBreadcrumbMock.mock.calls) { - expect(call[0]).toMatchObject({ coalesceKey: 'terminal_park_verdict_churn' }) + expect(call[0]).toMatchObject({ coalesceKey: 'terminal_park_verdict_churn:window' }) } }) + // Why: `burst` means damping engaged a commit short of React #185 while + // `window` is slow benign churn. A shared key would drop the near-crash + // signal into the slow-churn slot. + it('keeps burst and window park-verdict churn triggers in separate slots', () => { + emitRendererBreadcrumb({ + name: 'terminal_park_verdict_churn', + data: { tabId: 'tab-1', trigger: 'burst', flips: 3, elapsedMs: 4 } + }) + emitRendererBreadcrumb({ + name: 'terminal_park_verdict_churn', + data: { tabId: 'tab-1', trigger: 'window', flips: 12, elapsedMs: 900 } + }) + + expect( + recordCoalescedCrashBreadcrumbMock.mock.calls.map((call) => call[0].coalesceKey) + ).toEqual(['terminal_park_verdict_churn:burst', 'terminal_park_verdict_churn:window']) + }) + // Why: every hidden pane is 0x0, so one post-reload reattach wave exhausts // the fit budget once per mounted pane inside ~60ms. Windows crash // F0BKR84AHEH lost 26-90% of its 30-entry ring to two such bursts. @@ -298,6 +316,26 @@ describe('renderer breadcrumb IPC routing', () => { ]) }) + it('coalesces atlas resets per trigger reason', () => { + emitRendererBreadcrumb({ + name: 'terminal_webgl_diagnostic', + data: { kind: 'webgl-atlas-reset', reason: 'terminal-output' } + }) + emitRendererBreadcrumb({ + name: 'terminal_webgl_diagnostic', + data: { kind: 'webgl-atlas-reset', reason: 'system-resume' } + }) + + expect( + recordCoalescedCrashBreadcrumbMock.mock.calls.map( + (call) => (call[0] as { coalesceKey: string }).coalesceKey + ) + ).toEqual([ + 'terminal_webgl_diagnostic:webgl-atlas-reset:terminal-output', + 'terminal_webgl_diagnostic:webgl-atlas-reset:system-resume' + ]) + }) + // Why: the renderer guard is once per tab-id/verdict, so one stale worktree // map can emit enough crumbs to evict the pre-crash trail. it('coalesces duplicate-tab-owner notices across tabs', () => { diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index f028c7bc3b3..2094d0ee1c0 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -327,12 +327,13 @@ function buildUncapturedCrashReportText( // erasing the pre-crash trail. Coalesce repeats into one entry that carries a // suppressed count instead. const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees' +const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn' const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ 'renderer_error', 'renderer_unhandled_rejection', - 'terminal_park_verdict_churn', 'terminal_safe_fit_retry_exhausted', DUPLICATE_TAB_OWNER_BREADCRUMB, + PARK_VERDICT_CHURN_BREADCRUMB, TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB ]) const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 @@ -345,10 +346,7 @@ const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 // mounted pane within ~60ms. Windows crash F0BKR84AHEH lost 26-90% of its // 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count // — the only signal these carry — in one slot. -const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set([ - 'terminal_park_verdict_churn', - 'terminal_safe_fit_retry_exhausted' -]) +const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_safe_fit_retry_exhausted']) function rendererBreadcrumbCoalesceKey( name: string, @@ -357,12 +355,18 @@ function rendererBreadcrumbCoalesceKey( if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) { return name } - // Why kind and not name alone: a context loss (GPU/driver gave up on this - // renderer) and an atlas reset (routine post-wake repaint) must never - // suppress each other. Within one kind the count is the whole signal — every - // live pane emits on a GPU death. + // Why trigger and not name alone: `burst` means damping engaged a commit + // short of React #185, `window` means slow benign churn. Collapsing them + // would drop the near-crash signal into a slow-churn slot. Still bounded — + // two slots per storm regardless of tab count. + if (name === PARK_VERDICT_CHURN_BREADCRUMB) { + return `${name}:${String(data?.trigger ?? '')}` + } + // Preserve distinct GPU failures and atlas-reset triggers while coalescing each storm. if (name === TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB) { - return `${name}:${String(data?.kind ?? '')}` + const kind = String(data?.kind ?? '') + const reason = kind === 'webgl-atlas-reset' ? data?.reason : undefined + return reason ? `${name}:${kind}:${String(reason)}` : `${name}:${kind}` } // Why: a stale map can emit once per tab-id/verdict; key by verdict so // last-write coalescing cannot erase the other signal while remaining bounded. diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 78b950fe5a9..ad68010cbb7 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -44,6 +44,7 @@ const { cancelGeneratePullRequestFieldsLocalMock, getPullRequestDraftContextMock, resolveHostedReviewBodyForGenerationMock, + loadPullRequestLinkedIssueMock, getSshFilesystemProviderMock, getSshGitProviderMock, tryDeleteWslUncPathMock, @@ -89,6 +90,7 @@ const { cancelGeneratePullRequestFieldsLocalMock: vi.fn(), getPullRequestDraftContextMock: vi.fn(), resolveHostedReviewBodyForGenerationMock: vi.fn(), + loadPullRequestLinkedIssueMock: vi.fn(), getSshFilesystemProviderMock: vi.fn(), getSshGitProviderMock: vi.fn(), tryDeleteWslUncPathMock: vi.fn(), @@ -203,6 +205,10 @@ vi.mock('../source-control/pull-request-template', () => ({ resolveHostedReviewBodyForGeneration: resolveHostedReviewBodyForGenerationMock })) +vi.mock('../source-control/pull-request-linked-issue', () => ({ + loadPullRequestLinkedIssue: loadPullRequestLinkedIssueMock +})) + import { registerFilesystemHandlers } from './filesystem' import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth' @@ -305,6 +311,7 @@ describe('registerFilesystemHandlers', () => { generatePullRequestFieldsFromContextMock, getPullRequestDraftContextMock, resolveHostedReviewBodyForGenerationMock, + loadPullRequestLinkedIssueMock, discoverCommitMessageModelsLocalMock, discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, @@ -316,6 +323,7 @@ describe('registerFilesystemHandlers', () => { ]) { mock.mockReset() } + loadPullRequestLinkedIssueMock.mockResolvedValue(null) handleMock.mockImplementation((channel, handler) => { handlers.set(channel, handler) @@ -358,6 +366,30 @@ describe('registerFilesystemHandlers', () => { lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) }) + it('re-sorts SSH provider listings directories-first in natural order', async () => { + // Why: the remote relay may be an older build that still sorts lexicographically. + getSshFilesystemProviderMock.mockReturnValueOnce({ + readDir: vi.fn().mockResolvedValue([ + { name: '100 - b.txt', isDirectory: false, isSymlink: false }, + { name: '9 - c.txt', isDirectory: false, isSymlink: false }, + { name: '10 - dir', isDirectory: true, isSymlink: false }, + { name: '99 - a.txt', isDirectory: false, isSymlink: false } + ]) + }) + registerFilesystemHandlers(store as never) + + const result = (await handlers.get('fs:readDir')!(null, { + dirPath: '/remote/repo', + connectionId: 'ssh-1' + })) as { name: string }[] + expect(result.map((e) => e.name)).toEqual([ + '10 - dir', + '9 - c.txt', + '99 - a.txt', + '100 - b.txt' + ]) + }) + it('returns an actionable reconnect error when the SSH filesystem provider is unavailable', async () => { registerFilesystemHandlers(store as never) @@ -2244,6 +2276,13 @@ describe('registerFilesystemHandlers', () => { it('enriches the local pull-request context with a validated worktree linked issue', async () => { const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}` + const linkedIssueDetails = { + provider: 'github', + number: 123, + title: 'Improve PR generation', + description: 'Include issue context.' + } + loadPullRequestLinkedIssueMock.mockResolvedValue(linkedIssueDetails) const linkedStore = { ...store, getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined) @@ -2254,11 +2293,17 @@ describe('registerFilesystemHandlers', () => { await handlers.get('git:generatePullRequestFields')!(null, { ...PULL_REQUEST_ARGS, worktreePath: WORKTREE_FEATURE_PATH, - worktreeId + worktreeId, + provider: 'github' }) expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( - { ...PULL_REQUEST_CONTEXT, linkedIssue: 123 }, + { + ...PULL_REQUEST_CONTEXT, + linkedIssue: 123, + provider: 'github', + linkedIssueDetails + }, params, expect.objectContaining({ kind: 'local' }) ) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 48ae7f8f6f2..4ca9547b77c 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -30,6 +30,7 @@ import type { } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { SshMutationExpectation } from '../../shared/ssh-types' +import { sortDirEntries } from '../../shared/file-name-sort' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { buildRgArgs, @@ -105,7 +106,10 @@ import { getLocalGitOptionsForRepo, getLocalRepoForRegisteredWorktree } from './local-worktree-runtime-options' -import { resolveSourceControlAiLinkedIssue } from './source-control-ai-linked-issue' +import { + resolveSourceControlAiLinkedIssue, + resolveSourceControlAiLinkedIssueMeta +} from './source-control-ai-linked-issue' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' import { @@ -117,6 +121,7 @@ import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' +import { loadPullRequestLinkedIssue } from '../source-control/pull-request-linked-issue' import { prepareLocalCommitMessageAgentEnv, type CommitMessageAgentRuntimeTarget, @@ -134,6 +139,10 @@ import { sanitizeLocalDownloadFilename } from '../local-download-filename' import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' +import { + applyGitStatusUpstreamRefWatchRequest, + type GitStatusUpstreamRefWatchRequest +} from './git-status-upstream-ref-watch-request' // Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB @@ -532,7 +541,9 @@ export function registerFilesystemHandlers( if (args.connectionId) { throwSite = 'ssh-provider' const provider = requireSshFilesystemProvider(args.connectionId) - return await provider.readDir(args.dirPath) + // Why: re-sort locally — the remote relay may be an older build with + // lexicographic ordering. + return sortDirEntries(await provider.readDir(args.dirPath)) } throwSite = 'authorize' const dirPath = await resolveAuthorizedPath(args.dirPath, store) @@ -547,12 +558,7 @@ export function registerFilesystemHandlers( isSymlink: entry.isSymbolicLink() })) ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return sortDirEntries(mapped) } catch (error: unknown) { recordCrashBreadcrumb( 'fs_readdir_error', @@ -1119,6 +1125,7 @@ export function registerFilesystemHandlers( includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string } ): Promise => { @@ -1126,6 +1133,9 @@ export function registerFilesystemHandlers( const options = { includeIgnored: args.includeIgnored ?? false, ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), ...(args.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } : {}), @@ -1161,6 +1171,12 @@ export function registerFilesystemHandlers( gitStatusCancellations.cancel(event, args.requestToken) }) + ipcMain.handle( + 'git:setStatusUpstreamRefWatch', + (_event, args: GitStatusUpstreamRefWatchRequest): Promise => + applyGitStatusUpstreamRefWatchRequest(store, args) + ) + // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. ipcMain.handle( 'git:submoduleStatus', @@ -1617,6 +1633,13 @@ export function registerFilesystemHandlers( error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } } + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: args.worktreePath, + connectionId: args.connectionId + }) let context: Awaited> try { const currentBody = await resolveHostedReviewBodyForGeneration({ @@ -1645,10 +1668,12 @@ export function registerFilesystemHandlers( if (!context) { return { success: false, error: 'No branch changes to summarize.' } } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args) - ) + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, @@ -1664,6 +1689,14 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args, worktreePath) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: worktreePath, + connectionId: args.connectionId, + localGitOptions: gitOptions + }) let context: Awaited> try { const currentBody = await resolveHostedReviewBodyForGeneration({ @@ -1692,10 +1725,12 @@ export function registerFilesystemHandlers( if (!context) { return { success: false, error: 'No branch changes to summarize.' } } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args, worktreePath) - ) + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, commitMessageAgentEnv, diff --git a/src/main/ipc/git-status-upstream-ref-watch-request.test.ts b/src/main/ipc/git-status-upstream-ref-watch-request.test.ts new file mode 100644 index 00000000000..0af27cf088b --- /dev/null +++ b/src/main/ipc/git-status-upstream-ref-watch-request.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' + +const mocks = vi.hoisted(() => ({ + gitExec: vi.fn(), + getProvider: vi.fn(), + getProviderGeneration: vi.fn(), + resolveRegisteredPath: vi.fn(), + getLocalRepo: vi.fn(), + getLocalOptions: vi.fn(), + setWatch: vi.fn() +})) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.gitExec })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: mocks.getProvider, + getSshGitProviderGeneration: mocks.getProviderGeneration, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable' +})) +vi.mock('./filesystem-auth', () => ({ + resolveRegisteredWorktreePath: mocks.resolveRegisteredPath +})) +vi.mock('./local-worktree-runtime-options', () => ({ + getLocalRepoForRegisteredWorktree: mocks.getLocalRepo, + getLocalGitOptionsForRepo: mocks.getLocalOptions +})) +vi.mock('./worktree-base-directory-watcher', () => ({ + setWorktreeGitStatusRefWatch: mocks.setWatch +})) + +import { applyGitStatusUpstreamRefWatchRequest } from './git-status-upstream-ref-watch-request' + +describe('applyGitStatusUpstreamRefWatchRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getProviderGeneration.mockReturnValue(0) + mocks.resolveRegisteredPath.mockResolvedValue('/resolved/repo') + mocks.getLocalOptions.mockReturnValue({}) + mocks.setWatch.mockImplementation( + async (_args: unknown, resolve: (signal: AbortSignal) => Promise) => + resolve(new AbortController().signal) + ) + }) + + it('routes local exact resolution through the registered worktree runtime', async () => { + mocks.getLocalOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + mocks.gitExec.mockResolvedValue({ + stdout: 'refs/heads/feature/main\0refs/remotes/origin/feature/main\0origin/feature/main\n' + }) + + await applyGitStatusUpstreamRefWatchRequest({} as Store, { + worktreeId: 'repo-1::/repo', + worktreePath: '/repo', + executionHostId: 'wsl:Ubuntu', + branch: 'refs/heads/feature/main', + upstreamName: 'origin/feature/main' + }) + + expect(mocks.gitExec).toHaveBeenCalledWith( + [ + 'for-each-ref', + '--format=%(refname)%00%(upstream)%00%(upstream:short)', + '--count=1', + 'refs/heads/feature/main' + ], + expect.objectContaining({ cwd: '/resolved/repo', wslDistro: 'Ubuntu', timeout: 15_000 }) + ) + }) + + it('routes SSH resolution through the current provider generation', async () => { + const exec = vi.fn().mockResolvedValue({ + stdout: + 'refs/heads/feature/main\0refs/remotes/team/fork/feature/main\0team/fork/feature/main\n' + }) + mocks.getProvider.mockReturnValue({ exec }) + mocks.getProviderGeneration.mockReturnValue(7) + + await applyGitStatusUpstreamRefWatchRequest({} as Store, { + worktreeId: 'repo-1::/repo', + worktreePath: '/repo', + executionHostId: 'ssh:ssh-1', + connectionId: 'ssh-1', + branch: 'refs/heads/feature/main', + upstreamName: 'team/fork/feature/main' + }) + + expect(mocks.setWatch).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'ssh-1', providerGeneration: 7 }), + expect.any(Function) + ) + expect(exec).toHaveBeenCalledWith( + [ + 'for-each-ref', + '--format=%(refname)%00%(upstream)%00%(upstream:short)', + '--count=1', + 'refs/heads/feature/main' + ], + '/repo', + expect.objectContaining({ signal: expect.any(AbortSignal), timeoutMs: 15_000 }) + ) + }) +}) diff --git a/src/main/ipc/git-status-upstream-ref-watch-request.ts b/src/main/ipc/git-status-upstream-ref-watch-request.ts new file mode 100644 index 00000000000..e0c98ef30f1 --- /dev/null +++ b/src/main/ipc/git-status-upstream-ref-watch-request.ts @@ -0,0 +1,78 @@ +import type { Store } from '../persistence' +import { resolveGitStatusUpstreamRef } from '../git/status-upstream-ref' +import { gitExecFileAsync } from '../git/runner' +import { + getSshGitProvider, + getSshGitProviderGeneration, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from './filesystem-auth' +import { + getLocalGitOptionsForRepo, + getLocalRepoForRegisteredWorktree +} from './local-worktree-runtime-options' +import { setWorktreeGitStatusRefWatch } from './worktree-base-directory-watcher' +import type { GitStatusRefBindingRequest } from './worktree-git-status-ref-watch' + +export type GitStatusUpstreamRefWatchRequest = Omit< + GitStatusRefBindingRequest, + 'providerGeneration' +> + +const UPSTREAM_REF_RESOLUTION_TIMEOUT_MS = 15_000 + +function boundedSignal(signal: AbortSignal): AbortSignal { + return AbortSignal.any([signal, AbortSignal.timeout(UPSTREAM_REF_RESOLUTION_TIMEOUT_MS)]) +} + +export function applyGitStatusUpstreamRefWatchRequest( + store: Store, + args: GitStatusUpstreamRefWatchRequest +): Promise { + const providerGeneration = args.connectionId + ? getSshGitProviderGeneration(args.connectionId) + : undefined + return setWorktreeGitStatusRefWatch( + { ...args, ...(providerGeneration !== undefined ? { providerGeneration } : {}) }, + async (bindingSignal) => { + if (!args.branch || !args.upstreamName) { + return undefined + } + const signal = boundedSignal(bindingSignal) + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return resolveGitStatusUpstreamRef( + (gitArgs, cwd, requestSignal) => + provider.exec(gitArgs, cwd, { + signal: requestSignal, + timeoutMs: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS + }), + args.worktreePath, + args.branch, + args.upstreamName, + signal + ) + } + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) + const gitOptions = getLocalGitOptionsForRepo(store, repo) + return resolveGitStatusUpstreamRef( + (gitArgs, cwd, requestSignal) => + gitExecFileAsync(gitArgs, { + cwd, + signal: requestSignal, + timeout: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}) + }), + worktreePath, + args.branch, + args.upstreamName, + signal + ) + } + ) +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index efd9e3ac827..c91c7d23dfd 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -7071,6 +7071,41 @@ describe('registerPtyHandlers', () => { await expect(handlers.get('pty:hasPty')!(null, { id: 'maybe-pty' })).resolves.toBe(null) }) + it('never answers liveness for a paired-runtime handle from the local registry', async () => { + const hasPty = vi.fn(() => false) + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + hasPty, + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + + // The local provider would happily report `false` here — it just doesn't + // hold the id. Callers read that as "the shell died" (STA-2830). + await expect( + handlers.get('pty:hasPty')!(null, { id: 'remote:env-1@@terminal-1' }) + ).resolves.toBe(null) + expect(hasPty).not.toHaveBeenCalled() + }) + it('lists duplicate SSH relay session ids as distinct app sessions', async () => { registerPtyHandlers(mainWindow as never) const shutdownA = vi.fn(async () => undefined) @@ -9513,6 +9548,143 @@ describe('registerPtyHandlers', () => { expect(store.flushOrThrow).toHaveBeenCalledOnce() }) + // Why: a parked pane (stopped with keepHistory) leaves the runtime holding the binding while + // persistence has already dropped it. Reading "nothing left to retire" as a competing owner + // aborted materialization *after* signalling the exit, which destroyed the pane instead of + // rebuilding it — the reconnect path then had no surface to attach to (#11541). + it('respawns a proven-dead owner whose persisted binding was already retired', async () => { + const worktreeId = 'repo-1::/tmp/already-retired-owner' + const cwd = '/tmp/already-retired-owner' + const tabId = 'tab-already-retired-owner' + const leafId = '89898989-8989-4989-8989-898989898989' + const paneKey = makePaneKey(tabId, leafId) + const providerSpawn = vi.fn( + async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { + if (options.attachOnly) { + throw new Error('Session not found: pty-already-retired-owner') + } + return { id: 'pty-fresh-already-retired', incarnationId: 'inc-fresh-already-retired' } + } + ) + const probePtyLiveness = vi.fn(async () => false) + setLocalPtyProvider({ + spawn: providerSpawn, + probePtyLiveness, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + // Persistence kept the tab but already dropped this leaf's PTY binding, exactly as an + // earlier keep-history stop leaves it. + let session = { + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: null }] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + }, + terminalPtyIncarnationsByPaneKey: {} + } + const store = { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + } + let runtimeOwnsPane = true + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + if (!runtimeOwnsPane) { + throw new Error('terminal_not_found') + } + return { + ptyId: 'pty-already-retired-owner', + tabId, + leafId, + handle: 'term-already-retired', + connected: true + } + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-already-retired-fresh'), + preAllocateHandleForPty: vi.fn(() => 'term-already-retired-fresh'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + // Why: the real runtime drops its pane binding on exit; the guard after retirement must + // see that release rather than a resurrected owner. + onPtyExit: vi.fn(() => { + runtimeOwnsPane = false + }), + onPtyData: vi.fn() + } + + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + + const mounted = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd, + command: 'codex resume already-retired-session', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + + expect(probePtyLiveness).toHaveBeenCalledWith('pty-already-retired-owner') + expect(mounted).toMatchObject({ id: 'pty-fresh-already-retired' }) + expect(providerSpawn).toHaveBeenCalledTimes(2) + expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ + command: 'codex resume already-retired-session' + }) + expect(runtime.onPtyExit).toHaveBeenCalledWith('pty-already-retired-owner', 0, undefined) + }) + it('retires a dead owner from the exact SSH host session before fresh recovery', async () => { const connectionId = 'ssh-dead-stable-pane' const hostId = `ssh:${connectionId}` @@ -11651,7 +11823,7 @@ describe('registerPtyHandlers', () => { } }) - posixOnlyIt('wraps macOS spawns in login(1) with SHELL re-asserted via env(1)', async () => { + posixOnlyIt('wraps macOS spawns in login(1) with SHELL restored by the trampoline', async () => { const originalShell = process.env.SHELL // Re-enable the TCC login wrapper the suite-level beforeEach disables. delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL @@ -11675,8 +11847,14 @@ describe('registerPtyHandlers', () => { expect(args).toEqual([ '-flpq', userInfo().username, - '/usr/bin/env', - 'SHELL=/bin/zsh', + '/bin/bash', + '--noprofile', + '--norc', + '-p', + '-c', + 'export SHELL="$1"; shift; exec -l -- "$@"', + 'orca-tcc-login', + '/bin/zsh', '/bin/zsh', '-l' ]) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 0fc5d6e9d40..e4f50a62501 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -198,7 +198,6 @@ import { } from '../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { environmentCodexHomeOverrideContextsEqual, getCustomCodexHomeOverrideForLaunch, @@ -712,7 +711,13 @@ function retirePersistedStablePaneOwner( const paneKey = makePaneKey(owner.tabId, owner.leafId) const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (current?.ptyId !== owner.ptyId || current.incarnationId !== owner.incarnationId) { + if (!current) { + // Why: persistence already dropped this pane binding (an earlier stop retired it while the + // runtime kept history), so there is nothing left to clear — that is a completed retirement, + // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. + return true + } + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.incarnationId) { return false } const session = store.getWorkspaceSession(hostId) @@ -1111,10 +1116,7 @@ function shouldStripInheritedOrcaCodexHome(args: { settings: GlobalSettings | undefined }): boolean { return ( - args.target.runtime === 'host' && - args.selectedCodexHomePath === null && - !args.skipCodexHomeEnv && - isCodexSystemDefaultRealHomeEnabled() + args.target.runtime === 'host' && args.selectedCodexHomePath === null && !args.skipCodexHomeEnv ) } @@ -7153,6 +7155,13 @@ export function registerPtyHandlers( ) ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) { + // Why: same routing hazard pty:kill guards against — ptyOwnership never holds + // a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup + // falls through to the local provider and its "not in my table" reads as an + // authoritative dead. That is a fabricated answer about another host's PTY. + return null + } const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null const provider = parsedSshId diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 28d303d28a6..9d3732f886b 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -9,7 +9,9 @@ import { redactRuntimeEnvironment, type PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-queue' +import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import type { Store } from '../persistence' import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification' @@ -151,6 +153,25 @@ function registerPassiveStatusHandler(getUserDataPath: () => string): void { ) } +function runtimeEnvironmentCallFailure( + environment: ReturnType, + method: string, + error: unknown +): RuntimeRpcFailure | null { + if ( + !(error instanceof RemoteRuntimeClientError) && + !(error instanceof RuntimeRpcCallQueueOverloadError) + ) { + return null + } + return { + id: method, + ok: false, + error: { code: error.code, message: error.message }, + _meta: { runtimeId: environment.runtimeId } + } +} + function registerPassiveCallHandler(getUserDataPath: () => string): void { ipcMain.handle( 'runtimeEnvironments:call', @@ -168,14 +189,23 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void { if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { return manuallyDisconnectedResponse(environment) } - const response = await callRuntimeEnvironment( - getUserDataPath(), - environment.id, - args.method, - args.params, - args.timeoutMs, - args.expectedEnvironmentPairingRevision - ) + let response: RuntimeRpcResponse + try { + response = await callRuntimeEnvironment( + getUserDataPath(), + environment.id, + args.method, + args.params, + args.timeoutMs, + args.expectedEnvironmentPairingRevision + ) + } catch (error) { + const failure = runtimeEnvironmentCallFailure(environment, args.method, error) + if (failure) { + return failure + } + throw error + } return isRuntimeEnvironmentManuallyDisconnected(environment.id) ? manuallyDisconnectedResponse(environment) : response diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 0e3739d6ccb..9ca8da98600 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -11,6 +11,8 @@ import { } from '../../shared/protocol-version' import * as environmentStore from '../../shared/runtime-environment-store' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-queue' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' const { handleMock, @@ -935,6 +937,67 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(sendRemoteRuntimeSharedControlRequestMock).toHaveBeenCalledTimes(1) }) + it.each([ + [ + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Transport vanished without legacy classifier wording.' + ), + 'remote_runtime_unavailable', + 'Transport vanished without legacy classifier wording.' + ], + [ + Object.assign(new RuntimeRpcCallQueueOverloadError('selector'), { + message: 'Capacity rejected without legacy classifier wording.' + }), + 'runtime_rpc_queue_overloaded', + 'Capacity rejected without legacy classifier wording.' + ] + ])( + 'returns coded transport failure %s so the renderer restores its identity', + async (transportError, expectedCode, expectedMessage) => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockRejectedValue(transportError) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + const call = handler<{ selector: string; method: string }, RuntimeRpcResponse>( + 'runtimeEnvironments:call' + ) + + const response = structuredClone(await call(null, { selector: 'desk', method: 'status.get' })) + expect(response).toMatchObject({ + ok: false, + error: { code: expectedCode, message: expectedMessage } + }) + expect(response.ok).toBe(false) + if (response.ok === false) { + expect(response.error).toEqual({ code: expectedCode, message: expectedMessage }) + } + } + ) + + it('keeps uncoded call failures on the rejected IPC fallback path', async () => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('shared down')) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) + const call = handler<{ selector: string; method: string }, RuntimeRpcResponse>( + 'runtimeEnvironments:call' + ) + + await expect(call(null, { selector: 'desk', method: 'status.get' })).rejects.toThrow( + 'shared down' + ) + }) + it('does not fall back after a shared-control request fails on a supported runtime', async () => { registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockResolvedValue({ diff --git a/src/main/ipc/source-control-ai-linked-issue.ts b/src/main/ipc/source-control-ai-linked-issue.ts index 559d39c7399..fa000148fef 100644 --- a/src/main/ipc/source-control-ai-linked-issue.ts +++ b/src/main/ipc/source-control-ai-linked-issue.ts @@ -1,6 +1,7 @@ import { resolve } from 'node:path' import type { Store } from '../persistence' import { isLinkedIssueNumber } from '../../shared/source-control-ai-action-variables' +import type { WorktreeMeta } from '../../shared/types' import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' export type LinkedIssueLookupArgs = { @@ -54,11 +55,11 @@ function matchesRequestPath( * Meta is keyed by the raw id: the `::workspace:` suffix of folder-repo * workspace instances is part of the key, while validation uses the stripped path. */ -export function resolveSourceControlAiLinkedIssue( +export function resolveSourceControlAiLinkedIssueMeta( store: Store, args: LinkedIssueLookupArgs, resolvedWorktreePath?: string -): number | null { +): WorktreeMeta | null { if (typeof args.worktreeId !== 'string' || !args.worktreeId) { return null } @@ -77,7 +78,19 @@ export function resolveSourceControlAiLinkedIssue( if (!matchesRequestPath(parsed.worktreePath, args, resolvedWorktreePath)) { return null } - const linkedIssue = store.getWorktreeMeta(args.worktreeId)?.linkedIssue + return store.getWorktreeMeta(args.worktreeId) ?? null +} + +export function resolveSourceControlAiLinkedIssue( + store: Store, + args: LinkedIssueLookupArgs, + resolvedWorktreePath?: string +): number | null { + const linkedIssue = resolveSourceControlAiLinkedIssueMeta( + store, + args, + resolvedWorktreePath + )?.linkedIssue // Why: GitHub only in v1 — no `linkedGitLabIssue` dual-read. return isLinkedIssueNumber(linkedIssue) ? linkedIssue : null } diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 2356de614b0..f2a779ee412 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -3,6 +3,7 @@ import type { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshExecOptions } from '../ssh/ssh-connection-utils' import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' import type { FilesystemPathFlavor } from '../../shared/types' +import { sortDirEntries } from '../../shared/file-name-sort' export type RemoteDirEntry = { name: string @@ -220,13 +221,8 @@ async function runBrowseCommand( } } - // Sort: directories first, then alphabetical - entries.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + // Sort: directories first, then natural name order (matches the Explorer) + sortDirEntries(entries) resolveOnce({ entries, resolvedPath, pathFlavor }) } diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 6609899dce1..538cf4bf846 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -259,11 +259,13 @@ vi.mock('../ssh/ssh-port-scanner', () => ({ })) import { - detachAllSshSessionsForShutdown, + beginSshShutdown, + SSH_SHUTDOWN_BUDGET_MS, getActiveMultiplexer, getSshConnectionManager, registerSshHandlers, - resetSshHandlerStateForTests + resetSshHandlerStateForTests, + type SshShutdownResult } from './ssh' import { RelayVersionMismatchError } from '../ssh/ssh-relay-version-mismatch-error' import { @@ -306,6 +308,7 @@ describe('SSH IPC handlers', () => { markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), markSshRemotePtyLeasesAsync: vi.fn(), + markSshRemotePtyLeasesForShutdown: vi.fn(), markSshRemotePtyLeasesAttachedAsync: vi.fn(), removeSshRemotePtyLeases: vi.fn() } @@ -421,6 +424,7 @@ describe('SSH IPC handlers', () => { role: 'session-owner', ownerGeneration: 1, ownerLease: 'ipc-test-owner', + resumed: false, capabilities: { outputFlowControl: { version: 1, windowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } } @@ -587,7 +591,7 @@ describe('SSH IPC handlers', () => { mockMux.dispose.mockClear() mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - await detachAllSshSessionsForShutdown() + await beginSshShutdown() expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') @@ -596,6 +600,195 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnectAll).toHaveBeenCalled() }) + it('detaches every lease in memory before a slow forward removal can cross the final flush', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) + let releaseForwards!: () => void + vi.mocked(mockPortForwardManager.removeAllForwards).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseForwards = resolve + }) + ) + vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear() + quitTeardownStartGate.tryStart({ preventDefault() {} }) + + const shutdown = beginSshShutdown() + + // Why asserted with no await: the committed quit path calls store.flushAsync() on the very next + // line, so anything not already in memory at this instant can never reach the final snapshot. + expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-1', 'detached') + // Why 'detached' and never 'terminated': the app is letting go of the lease, not proving the + // remote shell died. Those PTYs keep running for the next attach. + expect(mockStore.markSshRemotePtyLeasesForShutdown).not.toHaveBeenCalledWith( + 'ssh-1', + 'terminated' + ) + expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + + releaseForwards() + await shutdown + }) + + it('repeats no state transition when shutdown is begun twice', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) + vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear() + quitTeardownStartGate.tryStart({ preventDefault() {} }) + + const first = beginSshShutdown() + const second = beginSshShutdown() + + expect(second).toBe(first) + await Promise.all([first, second]) + expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledExactlyOnceWith( + 'ssh-1', + 'detached' + ) + }) + + it('detaches the remaining sessions and still returns when one session throws mid-transition', async () => { + const targets: Record = { + 'ssh-1': { id: 'ssh-1', label: 'A', host: 'a.example.com', port: 22, username: 'deploy' }, + 'ssh-2': { id: 'ssh-2', label: 'B', host: 'b.example.com', port: 22, username: 'deploy' } + } + mockSshStore.getTarget.mockImplementation((id: string) => targets[id] ?? null) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockImplementation((targetId: string) => ({ + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0 + })) + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-2' }) + mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) + vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear() + // Why webContents.send: quit destroys the renderer, and that is what makes broadcastEmptyLists + // throw out of the pre-pass for whichever session reaches it first. + mockWindow.webContents.send.mockImplementation((_channel: string, payload: unknown) => { + if ((payload as { targetId?: string } | undefined)?.targetId === 'ssh-1') { + throw new Error('Object has been destroyed') + } + }) + quitTeardownStartGate.tryStart({ preventDefault() {} }) + + // Why not-throw rather than a resolved promise: the caller is a non-async will-quit listener, so + // a synchronous throw escapes it and skips killAllPty, the watchers and store.flushAsync() — the + // very flush that persists the detached leases this pre-pass just staged. + let shutdown!: Promise + expect(() => { + shutdown = beginSshShutdown() + }).not.toThrow() + // Why asserted before the await: the real flush starts on the next synchronous line. + expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-2', 'detached') + + const result = await shutdown + expect( + result.errors.some( + (error) => error instanceof Error && error.message === 'Object has been destroyed' + ) + ).toBe(true) + }) + + it('reports the target and phase left unfinished when the shutdown budget expires', async () => { + vi.useFakeTimers() + try { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) + // Why never resolved, on every call: a forward whose child never reports exit is exactly the + // case the shared deadline exists for, and it must not get a fresh budget per phase. + vi.mocked(mockPortForwardManager.removeAllForwards).mockImplementation( + () => new Promise(() => {}) + ) + quitTeardownStartGate.tryStart({ preventDefault() {} }) + + let settled = false + const shutdown = beginSshShutdown().then((value) => { + settled = true + return value + }) + // Why the whole budget and not a millisecond more: the entire drain/join/drain sequence has to + // fit inside one deadline, so advancing exactly that far must be enough to settle it. Asserting + // the flag rather than awaiting keeps a per-phase budget a failure instead of a hang. + await vi.advanceTimersByTimeAsync(SSH_SHUTDOWN_BUDGET_MS) + expect(settled).toBe(true) + const result = await shutdown + + expect(result.unfinished).toContainEqual({ targetId: 'ssh-1', phase: 'drain' }) + // Why no final-drain entry: the budget was already spent, so no later phase was awaited at all. + expect(result.unfinished.some((entry) => entry.phase === 'final-drain')).toBe(false) + // Why the lease still stands: the drain timing out says nothing about the remote PTYs, and the + // pre-pass already recorded the only thing that was ever provable. + expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-1', 'detached') + expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() + } finally { + vi.mocked(mockPortForwardManager.removeAllForwards).mockReset().mockResolvedValue(undefined) + vi.useRealTimers() + } + }) + + it('returns as soon as a fast shutdown drains rather than waiting out the budget', async () => { + vi.useFakeTimers() + try { + mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) + quitTeardownStartGate.tryStart({ preventDefault() {} }) + + const result = await beginSshShutdown() + + expect(result.unfinished).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('refuses the replacement session a paused connect would publish after shutdown began', async () => { const target: SshTarget = { id: 'ssh-1', @@ -639,7 +832,7 @@ describe('SSH IPC handlers', () => { mockConnectionManager.disconnectAll.mockClear() // Why latch the gate here: the committed quit path owns it, so the drain alone must not fence. quitTeardownStartGate.tryStart({ preventDefault() {} }) - const shutdown = detachAllSshSessionsForShutdown() + const shutdown = beginSshShutdown() releaseDetach() await expect(replacement).rejects.toThrow('closed for app shutdown') @@ -698,7 +891,7 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) quitTeardownStartGate.tryStart({ preventDefault() {} }) - const shutdown = detachAllSshSessionsForShutdown() + const shutdown = beginSshShutdown() openProbeTransport() await shutdown @@ -2467,7 +2660,7 @@ describe('SSH IPC handlers', () => { await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) }) - it('ssh:terminateSessions ignores expired leases when disconnected', async () => { + it('ssh:terminateSessions cannot reach expired leases without a relay', async () => { mockStore.getSshRemotePtyLeases.mockReturnValue([ { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' } ]) @@ -2482,6 +2675,140 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-abandoned', state: 'expired' } + ]) + vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + mockPtyProvider.shutdown.mockResolvedValue(undefined) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + + expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-abandoned', { + immediate: true, + keepHistory: false + }) + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'pty-abandoned', + 'terminated' + ) + }) + + it('ssh:terminateSessions tombstones an expired lease the relay reports gone', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-abandoned', state: 'expired' } + ]) + vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + mockPtyProvider.shutdown.mockRejectedValue(new Error('PTY "pty-abandoned" not found')) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'pty-abandoned', + 'terminated' + ) + }) + + it('ssh:terminateSessions leaves leases it already proved terminated alone', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-tombstoned', state: 'terminated' } + ]) + vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + mockPtyProvider.shutdown.mockResolvedValue(undefined) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + + expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() + }) + + it('ssh:terminateSessions keeps an expired lease when its shutdown fails', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-abandoned', state: 'expired' } + ]) + vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + mockPtyProvider.shutdown.mockRejectedValue(new Error('mux down')) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + await expect( + handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + ).rejects.toThrow('Failed to terminate SSH host sessions') + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'ssh-1', + 'pty-abandoned', + 'terminated' + ) + }) + it('ssh:resetRelay force-stops the remote relay and expires tracked leases', async () => { const target: SshTarget = { id: 'ssh-1', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 3b362aa7ef4..72fb560c10f 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1279,28 +1279,37 @@ export function registerSshHandlers( invalidateConnectAttempt(args.targetId) await runTargetLifecycle(args.targetId, async () => { const provider = getSshPtyProvider(args.targetId) - const leasedIds = persistedStore! - .getSshRemotePtyLeases(args.targetId) - .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') - .map((lease) => lease.ptyId) + const leases = persistedStore!.getSshRemotePtyLeases(args.targetId) const ptyIdsByRelayId = new Map() - for (const ptyId of getPtyIdsForConnection(args.targetId)) { + // Why: only leases the app still believes it owns may force a reconnect; 'expired' ones are + // swept opportunistically because they can name a host that is gone for good (issue #2626). + const ownedRelayIds = new Set() + const trackPtyId = (ptyId: string, owned: boolean): void => { const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId)) + if (!ptyIdsByRelayId.has(relayPtyId)) { + ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId)) + } + if (owned) { + ownedRelayIds.add(relayPtyId) + } } - for (const ptyId of leasedIds) { - const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - ptyIdsByRelayId.set( - relayPtyId, - ptyIdsByRelayId.get(relayPtyId) ?? toAppSshPtyId(args.targetId, ptyId) - ) + for (const ptyId of getPtyIdsForConnection(args.targetId)) { + trackPtyId(ptyId, true) + } + for (const lease of leases) { + if (lease.state === 'terminated') { + continue + } + // Why: 'expired' records that reattach gave up, never that the remote shell died — those are + // precisely the orphans, so the user's terminate action has to be able to reach them. + trackPtyId(lease.ptyId, lease.state !== 'expired') } const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ relayPtyId, appPtyId })) - if (ptyIds.length > 0 && !provider) { + if (ownedRelayIds.size > 0 && !provider) { throw new Error( `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` ) @@ -1581,59 +1590,149 @@ export function getSshConnectionManager(): SshConnectionManager | null { return connectionManager } -// Why: an invalidated connect only observes its cancellation at the next checkpoint, and one blocked -// in the transport handshake can sit there for the whole SSH timeout. Bound the join so the final -// drain always runs well inside the quit deadline instead of racing it. -const SSH_SHUTDOWN_INFLIGHT_JOIN_MS = 2_000 +// Why one budget for the whole sequence rather than one per phase: an invalidated connect only +// observes its cancellation at the next checkpoint, and one blocked in the transport handshake can +// sit there for the whole SSH timeout. A per-phase timeout lets any single phase consume the global +// quit deadline; a shared absolute deadline cannot. +export const SSH_SHUTDOWN_BUDGET_MS = 6_000 -async function settleWithinMs(work: readonly Promise[], timeoutMs: number): Promise { - if (work.length === 0) { - return - } - await Promise.race([ - Promise.allSettled(work), - new Promise((resolve) => { - const timer = setTimeout(resolve, timeoutMs) - timer.unref?.() - }) - ]) +export type SshShutdownPhase = 'drain' | 'in-flight-join' | 'final-drain' +export type SshShutdownUnfinished = { targetId: string; phase: SshShutdownPhase } +export type SshShutdownResult = { + unfinished: readonly SshShutdownUnfinished[] + errors: readonly unknown[] } +type SshShutdownTask = { targetId: string; promise: Promise } + +let sshShutdownDrain: Promise | null = null + +async function settleTasksWithinMs( + tasks: readonly SshShutdownTask[], + timeoutMs: number +): Promise<{ timedOut: SshShutdownTask[]; errors: unknown[] }> { + const pending = new Set(tasks) + const errors: unknown[] = [] + if (tasks.length === 0) { + return { timedOut: [], errors } + } + const tracked = tasks.map(async (task) => { + try { + await task.promise + } catch (error) { + errors.push(error) + } + pending.delete(task) + }) + let timer: ReturnType | undefined + try { + await Promise.race([ + Promise.all(tracked), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + timer.unref?.() + }) + ]) + } finally { + clearTimeout(timer) + } + return { timedOut: [...pending], errors } +} + +function sshShutdownTasks(targetIds: readonly string[]): SshShutdownTask[] { + return [ + ...targetIds + .filter((targetId) => activeSessions.has(targetId)) + .map((targetId) => ({ + targetId, + promise: teardownActiveSshSession(targetId, (session) => session.detachAndPersist()) + })), + { targetId: '*transports', promise: connectionManager?.disconnectAll() ?? Promise.resolve() } + ] +} + +async function drainSshShutdown( + targetIds: readonly string[], + inFlight: readonly SshShutdownTask[], + detachErrors: readonly unknown[] = [] +): Promise { + const deadline = Date.now() + SSH_SHUTDOWN_BUDGET_MS + const unfinished: SshShutdownUnfinished[] = [] + const errors: unknown[] = [...detachErrors] + const runPhase = async ( + phase: SshShutdownPhase, + tasks: readonly SshShutdownTask[] + ): Promise => { + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + unfinished.push(...tasks.map((task) => ({ targetId: task.targetId, phase }))) + return false + } + const settled = await settleTasksWithinMs(tasks, remainingMs) + errors.push(...settled.errors) + unfinished.push(...settled.timedOut.map((task) => ({ targetId: task.targetId, phase }))) + return settled.timedOut.length === 0 + } + + await runPhase('drain', sshShutdownTasks(targetIds)) + // Why a second drain after the join: a connect paused in old-session teardown still publishes its + // replacement session and opens a transport before it reaches the cancellation checkpoint, so the + // first drain can miss both. + if (await runPhase('in-flight-join', inFlight)) { + await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()])) + } + + if (errors.length > 0 || unfinished.length > 0) { + // Why one aggregate line: per-target logging on the quit path competes with the final flush for + // the little time that remains. + console.warn( + `[ssh] Shutdown drain finished with ${errors.length} error(s); unfinished: ${ + unfinished.map((entry) => `${entry.targetId}/${entry.phase}`).join(', ') || 'none' + }` + ) + } + return { unfinished, errors } +} + +// Why one entry point that returns rather than awaits: every in-memory transition the final store +// flush must snapshot happens synchronously here, before this returns, so the caller can start that +// flush with no await in between. Idempotent — a later call joins the same drain and repeats no +// state transition. +// // Why no fence latch here: the committed quit path sets it before calling this, so it is already on // for the snapshot below. Called without that gate (tests), this degrades to a plain drain. -export async function detachAllSshSessionsForShutdown(): Promise { - const inFlightWork: Promise[] = [ - ...[...connectInFlight.values()].map((attempt) => attempt.promise), - ...resetRelayInFlight.values(), - ...testConnectionProbes +export function beginSshShutdown(): Promise { + if (sshShutdownDrain) { + return sshShutdownDrain + } + const inFlight: SshShutdownTask[] = [ + ...[...connectInFlight.entries()].map(([targetId, attempt]) => ({ + targetId, + promise: attempt.promise + })), + ...[...resetRelayInFlight.entries()].map(([targetId, promise]) => ({ targetId, promise })), + ...[...testConnectionProbes].map((promise) => ({ targetId: '*probe', promise })) ] for (const targetId of Array.from(connectInFlight.keys())) { invalidateConnectAttempt(targetId) } - - const errors: unknown[] = [] - const drain = async (): Promise => { - const results = await Promise.allSettled([ - ...[...activeSessions.keys()].map((targetId) => - teardownActiveSshSession(targetId, (session) => session.detachAndPersist()) - ), - connectionManager?.disconnectAll() ?? Promise.resolve() - ]) - for (const result of results) { - if (result.status === 'rejected') { - errors.push(result.reason as unknown) - } + const targetIds = [...activeSessions.keys()] + // Why before any await: this is the whole point of the split. Each session marks its recovery lease + // detached in memory now, and the final flush persists it — the remote PTYs keep running. + const detachErrors: unknown[] = [] + for (const session of activeSessions.values()) { + // Why per-session: this runs synchronously inside a non-async will-quit listener, so one throw + // (teardownProviders -> webContents.send on a destroyed renderer, routine on quit) would escape + // it and skip every later session, the drain assignment, and the store flush that persists all + // of this. Collect and keep going; the drain reports them. + try { + session.beginShutdownDetach() + } catch (error) { + detachErrors.push(error) } } - - await drain() - // Why: a connect paused in old-session teardown still publishes its replacement session and opens a - // transport before it reaches the cancellation checkpoint, so the first drain can miss both. - await settleWithinMs(inFlightWork, SSH_SHUTDOWN_INFLIGHT_JOIN_MS) - await drain() - if (errors.length > 0) { - throw new AggregateError(errors, 'SSH shutdown detach failed') - } + sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors) + return sshShutdownDrain } export async function resetSshHandlerStateForTests(): Promise { @@ -1665,6 +1764,7 @@ export async function resetSshHandlerStateForTests(): Promise { testConnectionProbes.clear() credentialRequestedForTarget.clear() quitTeardownStartGate.resetForTests() + sshShutdownDrain = null await connectionManager?.disconnectAll() portForwardManager?.dispose() diff --git a/src/main/ipc/worktree-base-directory-event-filter.test.ts b/src/main/ipc/worktree-base-directory-event-filter.test.ts index 330be8b925c..5fad0bc7448 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.test.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts @@ -168,12 +168,67 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { }) }) + it('classifies config and the exact upstream ref as status-only external push signals', () => { + const target = makeGitCommonTarget() + // External `git push -u` writes only branch..remote/merge into the + // common config; without this signal the upstream stays invisible until a + // safety poll. + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'config') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) + const boundPaths = [ + join(COMMON_DIR, 'refs', 'remotes', 'origin', 'main'), + // Branch names with slashes nest arbitrarily deep. + join(COMMON_DIR, 'refs', 'remotes', 'team', 'fork', 'feature', 'nested'), + join(COMMON_DIR, 'refs', 'custom', 'origin', 'main') + ] + for (const path of boundPaths) { + target.gitStatusRefPaths = new Set([path]) + for (const type of ['create', 'update'] as const) { + expect(classifyWorktreeBaseChange(target, { type, path })).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) + } + } + // Ref-lock churn from an in-flight or aborted ref update stays invisible. + expect( + classifyWorktreeBaseChange(target, { + type: 'create', + path: join(COMMON_DIR, 'refs', 'remotes', 'origin', 'main.lock') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'refs', 'remotes', 'origin', 'unrelated') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) + }) + it('classifies Windows-shaped linked metadata paths', () => { const commonDir = win32.join('C:\\', 'repos', 'project', '.git') const target: WorktreeBaseWatchTarget = { ...makeGitCommonTarget(), key: `git-common:local:${commonDir}`, - path: commonDir + path: commonDir, + gitStatusRefPaths: new Set([win32.join(commonDir, 'refs', 'remotes', 'origin', 'main')]) } expect( classifyWorktreeBaseChange(target, { @@ -195,21 +250,36 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { gitStatusRepoIds: ['repo-1'], headIdentityRepoIds: [] }) + expect( + classifyWorktreeBaseChange(target, { + type: 'update', + path: win32.join(commonDir, 'refs', 'remotes', 'origin', 'main') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) }) it('ignores non-status common-dir churn', () => { const target = makeGitCommonTarget() for (const path of [ - join(COMMON_DIR, 'config'), join(COMMON_DIR, 'FETCH_HEAD'), join(COMMON_DIR, 'COMMIT_EDITMSG'), join(COMMON_DIR, 'objects', 'ab', 'cdef'), + // Local branch tips churn on every commit; head moves surface via logs/HEAD. join(COMMON_DIR, 'refs', 'heads', 'main'), - join(COMMON_DIR, 'logs', 'HEAD'), + // Remote-tracking reflogs churn alongside every push/fetch ref update. + join(COMMON_DIR, 'logs', 'refs', 'remotes', 'origin', 'main'), // Nested HEAD outside worktrees/ must not be mistaken for the primary's. join(COMMON_DIR, 'modules', 'sub', 'HEAD') ]) { - expect(matchingWorktreeBaseRepoIds(target, { type: 'update', path })).toEqual([]) + expect(classifyWorktreeBaseChange(target, { type: 'update', path })).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) } }) diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index 551af5a6568..7cf91ce80de 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -31,6 +31,8 @@ export type WorktreeBaseWatchTarget = { path: string connectionId?: string repos: Map + /** Exact upstream leaf selected by accepted active-worktree status. */ + gitStatusRefPaths?: ReadonlySet } export function pathRelativeToWorktreeWatchRoot( @@ -93,12 +95,16 @@ function matchingBaseRepoIds( // Why: branch switches and commits in the primary checkout rewrite these // top-level common-dir files; matching them keeps root-checkout branch/status -// as fresh as linked worktrees. Deeper churn (objects, refs, logs) is ignored. +// as fresh as linked worktrees. Deeper churn (objects, refs/heads, logs) is +// ignored. // `config.worktree` is structural because it is the only file whose write // flips `git worktree list`'s sparse flag, and no status/commit path touches // it — so it cannot re-open the index-churn fanout this classifier closes. const GIT_COMMON_PRIMARY_STRUCTURAL_FILES = new Set(['HEAD', 'packed-refs', 'config.worktree']) -const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index']) +// `config` is status-tier: an external `git push -u` writes only +// branch..remote/merge there, and a config write can move neither HEAD +// nor the worktree listing. +const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index', 'config']) const GIT_COMMON_LINKED_STRUCTURAL_FILES = new Set(['HEAD', 'gitdir', 'locked', 'config.worktree']) const GIT_COMMON_LINKED_STATUS_FILES = new Set(['index']) @@ -110,6 +116,21 @@ function isHeadLogParts(parts: string[], offset: number): boolean { return parts.length === offset + 2 && parts[offset] === 'logs' && parts[offset + 1] === 'HEAD' } +// Only the active status result's exact upstream ref crosses the refs/** cutoff. +function isBoundUpstreamRef( + target: WorktreeBaseWatchTarget, + eventPath: string, + parts: string[] +): boolean { + if (parts.length < 2 || parts[0] !== 'refs' || parts.at(-1)?.endsWith('.lock')) { + return false + } + const eventKey = normalizeRuntimePathForComparison(eventPath) + return [...(target.gitStatusRefPaths ?? [])].some( + (path) => normalizeRuntimePathForComparison(path) === eventKey + ) +} + function allRepoIds(target: WorktreeBaseWatchTarget): string[] { return [...target.repos.keys()] } @@ -171,6 +192,9 @@ function classifyGitCommonEvent( if (isHeadLogParts(parts, 0)) { return headIdentityChange(repoIds) } + if (isBoundUpstreamRef(target, event.path, parts)) { + return gitStatusChange(repoIds) + } return NO_CHANGE } if (parts.length === 2) { diff --git a/src/main/ipc/worktree-base-directory-notifications.ts b/src/main/ipc/worktree-base-directory-notifications.ts new file mode 100644 index 00000000000..e483f60b7b9 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-notifications.ts @@ -0,0 +1,78 @@ +import type { BrowserWindow } from 'electron' +import type { WorktreeBaseCollectedChanges } from './worktree-base-directory-change-collector' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { + refreshWorktreeHeadIdentities, + type WorktreeHeadIdentityRefreshState +} from './worktree-head-identity-refresh' +import { notifyWorktreeGitStatusMetadataChanged, notifyWorktreesChanged } from './worktree-remote' + +export type WorktreeBaseNotificationWatch = WorktreeBaseWatchTarget & { + mainWindow: BrowserWindow + notifyTimer: ReturnType | null + pendingStructureRepoIds: Set + pendingGitStatusRepoIds: Set + pendingHeadIdentityRepoIds: Set + headIdentityRefresh: WorktreeHeadIdentityRefreshState + disposed: boolean +} + +const WATCH_DEBOUNCE_MS = 250 + +export function clearPendingWorktreeBaseNotifications(watch: WorktreeBaseNotificationWatch): void { + watch.pendingStructureRepoIds.clear() + watch.pendingGitStatusRepoIds.clear() + watch.pendingHeadIdentityRepoIds.clear() +} + +export function supportsWorktreeHeadIdentityRefresh(watch: WorktreeBaseNotificationWatch): boolean { + return watch.kind === 'git-common' && !watch.connectionId +} + +export function scheduleWorktreeBaseNotification( + watch: WorktreeBaseNotificationWatch, + changes: Partial> +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + clearPendingWorktreeBaseNotifications(watch) + return + } + for (const repoId of changes.structureRepoIds ?? []) { + watch.pendingStructureRepoIds.add(repoId) + } + for (const repoId of changes.gitStatusRepoIds ?? []) { + watch.pendingGitStatusRepoIds.add(repoId) + } + for (const repoId of changes.headIdentityRepoIds ?? []) { + watch.pendingHeadIdentityRepoIds.add(repoId) + } + clearTimeout(watch.notifyTimer ?? undefined) + watch.notifyTimer = setTimeout(() => { + watch.notifyTimer = null + if (watch.disposed || watch.mainWindow.isDestroyed()) { + clearPendingWorktreeBaseNotifications(watch) + return + } + const pendingStructure = [...watch.pendingStructureRepoIds] + const hasHeadIdentity = watch.pendingHeadIdentityRepoIds.size > 0 + const sourceControlRepoIds = new Set( + [...watch.pendingGitStatusRepoIds, ...watch.pendingHeadIdentityRepoIds].filter( + (repoId) => !watch.pendingStructureRepoIds.has(repoId) + ) + ) + const emitHeadIdentities = pendingStructure.length === 0 + clearPendingWorktreeBaseNotifications(watch) + for (const repoId of pendingStructure) { + notifyWorktreesChanged(watch.mainWindow, repoId) + } + for (const repoId of sourceControlRepoIds) { + notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId) + } + if ( + supportsWorktreeHeadIdentityRefresh(watch) && + (pendingStructure.length > 0 || hasHeadIdentity) + ) { + void refreshWorktreeHeadIdentities(watch, watch.headIdentityRefresh, emitHeadIdentities) + } + }, WATCH_DEBOUNCE_MS) +} diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 31a068146c8..2f65e0fc282 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -59,6 +59,8 @@ export type WorktreeBasePollerOptions = { pollIntervalMs?: number platform?: NodeJS.Platform visibility?: WorktreePollerWindowVisibility + getGitStatusRefPaths?: () => readonly string[] + onWatchError?: (error: Error) => void /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void } @@ -349,7 +351,9 @@ export async function startWorktreeBaseDirectoryPoller( pollIntervalMs, platform, visibility, - options.onFullScan + options.onFullScan, + options.getGitStatusRefPaths, + options.onWatchError ) } return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan) diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 0a5773b13a0..5a9f8852c84 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join, sep } from 'node:path' import type { GlobalSettings, Repo } from '../../shared/types' -import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' +import type { + WorktreeBasePollEvent, + WorktreeBasePollerOptions +} from './worktree-base-directory-poller' vi.mock('fs/promises', () => ({ readFile: vi.fn(async () => ''), @@ -41,6 +44,7 @@ import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' import { disposeWorktreeBaseDirectoryWatchers, + setWorktreeGitStatusRefWatch, syncWorktreeBaseDirectoryWatchers } from './worktree-base-directory-watcher' import { matchingWorktreeBaseRepoIds } from './worktree-base-directory-event-filter' @@ -49,6 +53,7 @@ type PollerCallback = (events: WorktreeBasePollEvent[]) => void const watcherCallbacks = new Map() const unsubscribeMocks = new Map>() +const pollerOptions = new Map() const absolutePath = (...parts: string[]): string => join(sep, ...parts) const WORKTREE_ROOT = absolutePath('workspace', 'worktrees') const PROJECT_ROOT = absolutePath('workspace', 'projects', 'project') @@ -97,13 +102,15 @@ describe('worktree base directory watcher', () => { vi.useFakeTimers() watcherCallbacks.clear() unsubscribeMocks.clear() + pollerOptions.clear() vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined) vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([]) vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation( - async (target, _getRepos, onEvents) => { + async (target, _getRepos, onEvents, options) => { const unsubscribe = vi.fn(async () => {}) watcherCallbacks.set(target.path, onEvents) unsubscribeMocks.set(target.path, unsubscribe) + pollerOptions.set(target.path, options ?? {}) return { unsubscribe } } ) @@ -203,6 +210,149 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') }) + it('keeps one active-worktree upstream ref binding and ignores stale cleanup', async () => { + const firstWorktreeId = `repo-1::${PROJECT_ROOT}` + const nextWorktreeId = `repo-1::${absolutePath('workspace', 'worktrees', 'next')}` + await setWorktreeGitStatusRefWatch( + { + worktreeId: firstWorktreeId, + worktreePath: PROJECT_ROOT, + executionHostId: 'local', + branch: 'refs/heads/first', + upstreamName: 'origin/first' + }, + async () => 'refs/remotes/origin/first' + ) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const getPaths = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.getGitStatusRefPaths + expect(getPaths?.()).toEqual([join(PROJECT_GIT_COMMON_DIR, 'refs/remotes/origin/first')]) + + await setWorktreeGitStatusRefWatch( + { + worktreeId: nextWorktreeId, + worktreePath: absolutePath('workspace', 'worktrees', 'next'), + executionHostId: 'local', + branch: 'refs/heads/next', + upstreamName: 'origin/next' + }, + async () => 'refs/remotes/origin/next' + ) + await setWorktreeGitStatusRefWatch( + { + worktreeId: firstWorktreeId, + worktreePath: PROJECT_ROOT, + executionHostId: 'local' + }, + async () => undefined + ) + expect(getPaths?.()).toEqual([join(PROJECT_GIT_COMMON_DIR, 'refs/remotes/origin/next')]) + + await setWorktreeGitStatusRefWatch( + { + worktreeId: nextWorktreeId, + worktreePath: absolutePath('workspace', 'worktrees', 'next'), + executionHostId: 'local', + branch: 'refs/heads/next', + upstreamName: 'origin/next-invalid' + }, + async () => 'refs/remotes/origin/../escape' + ) + expect(getPaths?.()).toEqual([]) + }) + + it('filters SSH common-dir events to the active worktree upstream ref', async () => { + const remoteCallbacks = new Map void>() + const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => { + remoteCallbacks.set(root, callback) + return vi.fn() + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })), + realpath: vi.fn(async (path: string) => path), + readFile: vi.fn(async () => ({ content: '', isBinary: false })), + watch: remoteWatch + } as never) + + await syncWorktreeBaseDirectoryWatchers( + makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never, + makeWindow() as never + ) + await setWorktreeGitStatusRefWatch( + { + worktreeId: 'repo-1::/home/alice/project', + worktreePath: '/home/alice/project', + executionHostId: 'ssh:ssh-1', + connectionId: 'ssh-1', + branch: 'refs/heads/feature', + upstreamName: 'origin/feature' + }, + async () => 'refs/remotes/origin/feature' + ) + remoteCallbacks.get('/home/alice/project/.git')?.([ + { + kind: 'update', + absolutePath: '/home/alice/project/.git/refs/remotes/origin/unrelated' + } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled() + + remoteCallbacks.get('/home/alice/project/.git')?.([ + { + kind: 'update', + absolutePath: '/home/alice/project/.git/refs/remotes/origin/feature' + } + ] as never[]) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + }) + + it('invalidates exact resolution only when common config changes', async () => { + const worktreeId = `repo-1::${PROJECT_ROOT}` + const request = { + worktreeId, + worktreePath: PROJECT_ROOT, + executionHostId: 'local', + branch: 'refs/heads/feature', + upstreamName: 'origin/feature' + } + const resolve = vi.fn(async () => 'refs/remotes/origin/feature') + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + + await setWorktreeGitStatusRefWatch(request, resolve) + emit(PROJECT_GIT_COMMON_DIR, [ + { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'refs/remotes/origin/feature') } + ]) + await setWorktreeGitStatusRefWatch(request, resolve) + expect(resolve).toHaveBeenCalledOnce() + + emit(PROJECT_GIT_COMMON_DIR, [{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'config') }]) + await setWorktreeGitStatusRefWatch(request, resolve) + expect(resolve).toHaveBeenCalledTimes(2) + }) + + it('invalidates exact resolution before a local watcher error refresh', async () => { + const request = { + worktreeId: `repo-1::${PROJECT_ROOT}`, + worktreePath: PROJECT_ROOT, + executionHostId: 'local', + branch: 'refs/heads/feature', + upstreamName: 'origin/feature' + } + const resolve = vi.fn(async () => 'refs/remotes/origin/feature') + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await setWorktreeGitStatusRefWatch(request, resolve) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onWatchError?.(new Error('watch interrupted')) + await setWorktreeGitStatusRefWatch(request, resolve) + warn.mockRestore() + + expect(resolve).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + }) + it('keeps linked HEAD and lock metadata structural', async () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) @@ -530,6 +680,16 @@ describe('worktree base directory watcher', () => { makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never, makeWindow() as never ) + const request = { + worktreeId: 'repo-1::/home/alice/project', + worktreePath: '/home/alice/project', + executionHostId: 'ssh:ssh-1', + connectionId: 'ssh-1', + branch: 'refs/heads/feature', + upstreamName: 'origin/feature' + } + const resolve = vi.fn(async () => 'refs/remotes/origin/feature') + await setWorktreeGitStatusRefWatch(request, resolve) remoteCallbacks.get('/home/alice/project/.git')?.([ { @@ -541,12 +701,16 @@ describe('worktree base directory watcher', () => { await vi.advanceTimersByTimeAsync(300) expect(notifyWorktreesChanged).not.toHaveBeenCalled() expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + await setWorktreeGitStatusRefWatch(request, resolve) + expect(resolve).toHaveBeenCalledOnce() vi.mocked(notifyWorktreeGitStatusMetadataChanged).mockClear() remoteCallbacks.get('/home/alice/project/.git')?.([{ kind: 'overflow' }] as never[]) await vi.advanceTimersByTimeAsync(300) expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled() + await setWorktreeGitStatusRefWatch(request, resolve) + expect(resolve).toHaveBeenCalledTimes(2) }) it('unsubscribes roots that disappear after repo settings change', async () => { diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 85cd501a2eb..382a21dfc08 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -1,6 +1,5 @@ import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' -import { notifyWorktreeGitStatusMetadataChanged, notifyWorktreesChanged } from './worktree-remote' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { createWorktreeHeadIdentityRefreshState, @@ -12,6 +11,11 @@ import { collectRemoteWorktreeBaseChanges, type WorktreeBaseCollectedChanges } from './worktree-base-directory-change-collector' +import { + clearPendingWorktreeBaseNotifications, + scheduleWorktreeBaseNotification, + supportsWorktreeHeadIdentityRefresh +} from './worktree-base-directory-notifications' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import { buildWorktreeBaseDirectoryWatchTargets, @@ -21,6 +25,14 @@ import { createWorktreePollerWindowVisibility, startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' +import { + applyActiveGitStatusRefBinding, + clearActiveGitStatusRefBinding, + invalidateActiveGitStatusRefResolution, + invalidateGitStatusRefResolutionForPaths, + updateActiveGitStatusRefBinding, + type GitStatusRefBindingRequest +} from './worktree-git-status-ref-watch' type ActiveWatch = WorktreeBaseWatchTarget & { mainWindow: BrowserWindow @@ -30,76 +42,19 @@ type ActiveWatch = WorktreeBaseWatchTarget & { pendingGitStatusRepoIds: Set pendingHeadIdentityRepoIds: Set headIdentityRefresh: WorktreeHeadIdentityRefreshState + gitStatusRefPaths: Set disposed: boolean } -const WATCH_DEBOUNCE_MS = 250 const activeWatches = new Map() let syncGeneration = 0 let scheduledSync: ReturnType | null = null let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null - -function clearPendingRepoIds(watch: ActiveWatch): void { - watch.pendingStructureRepoIds.clear() - watch.pendingGitStatusRepoIds.clear() - watch.pendingHeadIdentityRepoIds.clear() -} - -type PendingNotificationInput = Partial> - -function scheduleNotification(watch: ActiveWatch, changes: PendingNotificationInput): void { - if (watch.disposed || watch.mainWindow.isDestroyed()) { - clearPendingRepoIds(watch) - return - } - for (const repoId of changes.structureRepoIds ?? []) { - watch.pendingStructureRepoIds.add(repoId) - } - for (const repoId of changes.gitStatusRepoIds ?? []) { - watch.pendingGitStatusRepoIds.add(repoId) - } - for (const repoId of changes.headIdentityRepoIds ?? []) { - watch.pendingHeadIdentityRepoIds.add(repoId) - } - // clearTimeout tolerates null (no-op), so no guard needed before rescheduling. - clearTimeout(watch.notifyTimer ?? undefined) - watch.notifyTimer = setTimeout(() => { - watch.notifyTimer = null - if (watch.disposed || watch.mainWindow.isDestroyed()) { - clearPendingRepoIds(watch) - return - } - const pendingStructure = [...watch.pendingStructureRepoIds] - const hasHeadIdentity = watch.pendingHeadIdentityRepoIds.size > 0 - // Source Control refreshes on both index churn and head moves; structural - // repos already refresh via the authoritative listing, so drop them here. - const sourceControlRepoIds = new Set( - [...watch.pendingGitStatusRepoIds, ...watch.pendingHeadIdentityRepoIds].filter( - (repoId) => !watch.pendingStructureRepoIds.has(repoId) - ) - ) - // Structural ticks refresh silently (emit=false): the authoritative listing - // already reported them, so this only re-baselines ahead of later head diffs. - const emitHeadIdentities = pendingStructure.length === 0 - clearPendingRepoIds(watch) - for (const repoId of pendingStructure) { - notifyWorktreesChanged(watch.mainWindow, repoId) - } - for (const repoId of sourceControlRepoIds) { - notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId) - } - // Only re-read head identities for true head triggers: an index rewrite - // cannot move HEAD, so status-only bursts skip the linked-worktree scan. - if (supportsHeadIdentityRefresh(watch) && (pendingStructure.length > 0 || hasHeadIdentity)) { - void refreshWorktreeHeadIdentities(watch, watch.headIdentityRefresh, emitHeadIdentities) - } - }, WATCH_DEBOUNCE_MS) -} - -// Why: SSH common dirs would need per-signal network reads to diff heads; -// remote background freshness stays on the structural path for now. -function supportsHeadIdentityRefresh(watch: ActiveWatch): boolean { - return watch.kind === 'git-common' && !watch.connectionId +export function setWorktreeGitStatusRefWatch( + args: GitStatusRefBindingRequest, + resolveUpstreamRef: (signal: AbortSignal) => Promise +): Promise { + return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef) } function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean { @@ -118,12 +73,18 @@ function handleLocalWatchEvents( } if (error) { console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) - scheduleNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) return } + invalidateGitStatusRefResolutionForPaths( + watch, + events.map((event) => event.path), + () => activeWatches.values() + ) const changes = collectLocalWorktreeBaseChanges(watch, events) if (hasCollectedChanges(changes)) { - scheduleNotification(watch, changes) + scheduleWorktreeBaseNotification(watch, changes) } } @@ -134,20 +95,29 @@ function handleRemoteWatchEvents( if (watch.disposed || watch.mainWindow.isDestroyed()) { return } + invalidateGitStatusRefResolutionForPaths( + watch, + events.flatMap((event) => + event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath] + ), + () => activeWatches.values() + ) const changes = collectRemoteWorktreeBaseChanges(watch, events) if (changes.overflow) { - scheduleNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) return } if (hasCollectedChanges(changes)) { - scheduleNotification(watch, changes) + scheduleWorktreeBaseNotification(watch, changes) } } function createActiveWatch( target: WorktreeBaseWatchTarget, mainWindow: BrowserWindow, - subscription: ActiveWatch['subscription'] + subscription: ActiveWatch['subscription'], + gitStatusRefPaths: Set ): ActiveWatch { return { ...target, @@ -158,6 +128,7 @@ function createActiveWatch( pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), + gitStatusRefPaths, disposed: false } } @@ -167,6 +138,8 @@ async function subscribeTarget( mainWindow: BrowserWindow ): Promise { let activeWatch: ActiveWatch | null = null + const gitStatusRefPaths = new Set() + applyActiveGitStatusRefBinding({ ...target, gitStatusRefPaths }) if (target.connectionId) { const provider = getSshFilesystemProvider(target.connectionId) if (!provider) { @@ -179,9 +152,12 @@ async function subscribeTarget( } handleRemoteWatchEvents(currentWatch, events) }) - activeWatch = createActiveWatch(target, mainWindow, { - unsubscribe: async () => unwatch() - }) + activeWatch = createActiveWatch( + target, + mainWindow, + { unsubscribe: async () => unwatch() }, + gitStatusRefPaths + ) return activeWatch } @@ -201,11 +177,18 @@ async function subscribeTarget( { visibility: createWorktreePollerWindowVisibility( () => (activeWatches.get(target.key) ?? activeWatch)?.mainWindow ?? null - ) + ), + getGitStatusRefPaths: () => [...gitStatusRefPaths], + onWatchError: (error) => { + const currentWatch = activeWatches.get(target.key) ?? activeWatch + if (currentWatch && !currentWatch.disposed) { + handleLocalWatchEvents(currentWatch, error, []) + } + } } ) - activeWatch = createActiveWatch(target, mainWindow, subscription) - if (supportsHeadIdentityRefresh(activeWatch)) { + activeWatch = createActiveWatch(target, mainWindow, subscription, gitStatusRefPaths) + if (supportsWorktreeHeadIdentityRefresh(activeWatch)) { // Baseline eagerly so the first status-only signal — possibly hours after // subscribe — diffs against subscribe-time heads instead of silently // re-baselining past an external commit. @@ -223,6 +206,7 @@ async function replaceWatch( if (previous) { previous.repos = target.repos previous.mainWindow = mainWindow + applyActiveGitStatusRefBinding(previous) return } try { @@ -234,6 +218,7 @@ async function replaceWatch( }) return } + applyActiveGitStatusRefBinding(activeWatch) activeWatches.set(target.key, activeWatch) } catch (error) { console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error) @@ -248,7 +233,7 @@ async function removeWatch(key: string): Promise { activeWatches.delete(key) watch.disposed = true clearTimeout(watch.notifyTimer ?? undefined) - clearPendingRepoIds(watch) + clearPendingWorktreeBaseNotifications(watch) await watch.subscription.unsubscribe().catch((error) => { console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) }) @@ -327,6 +312,7 @@ export function scheduleCurrentWorktreeBaseDirectoryWatcherSync(): void { export async function disposeWorktreeBaseDirectoryWatchers(): Promise { syncGeneration++ latestSyncContext = null + clearActiveGitStatusRefBinding() if (scheduledSync) { clearTimeout(scheduledSync) scheduledSync = null diff --git a/src/main/ipc/worktree-git-common-entry-snapshot.ts b/src/main/ipc/worktree-git-common-entry-snapshot.ts new file mode 100644 index 00000000000..d14f4ec8a1d --- /dev/null +++ b/src/main/ipc/worktree-git-common-entry-snapshot.ts @@ -0,0 +1,76 @@ +import { stat } from 'node:fs/promises' +import { join } from 'node:path' + +const STRUCTURAL_METADATA_FILES = ['HEAD', 'gitdir', 'locked', 'config.worktree'] +const INDEX_FILE = 'index' +const HEAD_LOG_FILE = join('logs', 'HEAD') + +function statSignature(value: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${value.mtimeMs}:${value.ctimeMs}:${value.ino}` +} + +export async function gitCommonDirectorySignature(path: string): Promise { + try { + const value = await stat(path) + return `${statSignature(value)}:${value.size}` + } catch { + return 'missing' + } +} + +export async function gitCommonFileSignature(path: string): Promise { + try { + const value = await stat(path) + return value.isFile() ? `${statSignature(value)}:${value.size}` : null + } catch { + return null + } +} + +export type GitCommonEntrySnapshot = { + dirSignature: string + structuralSignatures: Map + indexSignature: string | null + headLogSignature: string | null +} + +export async function snapshotGitCommonEntry( + entryPath: string, + previous: GitCommonEntrySnapshot | undefined, + forceFullScan: boolean +): Promise { + // Structural leaves change in place every tick; only index uses the entry-dir gate. + const structuralSignatures = new Map() + const [nextDirSignature, headLogSignature] = await Promise.all([ + gitCommonDirectorySignature(entryPath), + gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)), + Promise.all( + STRUCTURAL_METADATA_FILES.map(async (name) => { + const signature = await gitCommonFileSignature(join(entryPath, name)) + if (signature !== null) { + structuralSignatures.set(name, signature) + } + }) + ) + ]) + if (nextDirSignature === 'missing') { + return ( + previous ?? { + dirSignature: nextDirSignature, + structuralSignatures, + indexSignature: null, + headLogSignature + } + ) + } + const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature + const indexSignature = shouldReadIndex + ? await gitCommonFileSignature(join(entryPath, INDEX_FILE)) + : previous.indexSignature + return { + dirSignature: nextDirSignature, + structuralSignatures, + indexSignature, + headLogSignature + } +} diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index ed0043b3c5b..dae122943a3 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -1,21 +1,28 @@ -import { readdir, stat } from 'node:fs/promises' +import { readdir } from 'node:fs/promises' import { join } from 'node:path' import type { WorktreeBasePollEvent, WorktreeBaseSubscription, WorktreePollerWindowVisibility } from './worktree-base-directory-poller' +import { + gitCommonDirectorySignature, + gitCommonFileSignature, + snapshotGitCommonEntry, + type GitCommonEntrySnapshot +} from './worktree-git-common-entry-snapshot' // Shared with the darwin primary-metadata poll so platforms cannot drift. -// `logs/HEAD` catches head moves; `config.worktree` carries the sparse flag. +// `logs/HEAD` catches head moves; `config.worktree` carries the sparse flag; +// `config` gains branch..remote/merge on an external `git push -u`. export const PRIMARY_CHECKOUT_METADATA_FILES = [ 'HEAD', 'packed-refs', 'index', + 'config', 'config.worktree', 'logs/HEAD' ] -const LINKED_WORKTREE_STRUCTURAL_METADATA_FILES = ['HEAD', 'gitdir', 'locked', 'config.worktree'] const LINKED_WORKTREE_INDEX_FILE = 'index' const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD') // Why: the entry-dir signature gate can miss same-granule index rewrites on @@ -23,87 +30,28 @@ const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD') // same way the base poller's backstop rescan does. const INDEX_BACKSTOP_TICKS = 15 -function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { - return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` -} - -async function dirSignature(path: string): Promise { - try { - // Why: keep `size` — on a coarse-timestamp filesystem a same-granule directory - // allocation change would otherwise slip the readdir gate to the backstop. - const s = await stat(path) - return `${statSignature(s)}:${s.size}` - } catch { - return 'missing' - } -} - -async function fileSignature(path: string): Promise { - try { - const s = await stat(path) - return s.isFile() ? `${statSignature(s)}:${s.size}` : null - } catch { - return null - } -} - -type GitCommonEntrySnapshot = { - dirSignature: string - structuralSignatures: Map - indexSignature: string | null - headLogSignature: string | null -} - type GitCommonSnapshot = { worktreesDirSignature: string entries: Map primarySignatures: Map + statusRefPaths: Set + statusRefSignatures: Map didFullScan: boolean } -async function snapshotGitCommonEntry( - entryPath: string, - previous: GitCommonEntrySnapshot | undefined, - forceFullScan: boolean -): Promise { - // Why: HEAD, gitdir, locked, config.worktree and logs/HEAD are rewritten in place without bumping - // the entry-dir mtime, so — like the pre-idle-gate poller — they are re-stat'd EVERY tick, never - // gated behind the dir signature (else a raw HEAD/structural rewrite would slip to the ~30s - // backstop). Only `index` rides the entry-dir signature (its same-dir rewrites are index-backstop-bounded). - const structuralSignatures = new Map() - const [nextDirSignature, headLogSignature] = await Promise.all([ - dirSignature(entryPath), - fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE)), - Promise.all( - LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => { - const signature = await fileSignature(join(entryPath, name)) - if (signature !== null) { - structuralSignatures.set(name, signature) - } - }) - ) - ]) - if (nextDirSignature === 'missing') { - // A transient stat failure must not masquerade as a removal; the parent listing is authoritative. - return ( - previous ?? { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature: null, - headLogSignature +async function snapshotStatusRefSignatures( + paths: ReadonlySet +): Promise> { + const signatures = new Map() + await Promise.all( + [...paths].map(async (path) => { + const signature = await gitCommonFileSignature(path) + if (signature !== null) { + signatures.set(path, signature) } - ) - } - const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature - const indexSignature = shouldReadIndex - ? await fileSignature(join(entryPath, LINKED_WORKTREE_INDEX_FILE)) - : previous.indexSignature - return { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature, - headLogSignature - } + }) + ) + return signatures } async function snapshotPrimaryCheckoutSignatures( @@ -112,7 +60,7 @@ async function snapshotPrimaryCheckoutSignatures( const signatures = new Map() await Promise.all( PRIMARY_CHECKOUT_METADATA_FILES.map(async (name) => { - const signature = await fileSignature(join(commonDirPath, name)) + const signature = await gitCommonFileSignature(join(commonDirPath, name)) if (signature !== null) { signatures.set(name, signature) } @@ -125,12 +73,14 @@ async function snapshotGitCommon( commonDirPath: string, previous?: GitCommonSnapshot, includePrimary = true, - forceFullScan = false + forceFullScan = false, + statusRefPaths: Set = new Set() ): Promise { const worktreesDir = join(commonDirPath, 'worktrees') - const [worktreesDirSignature, primarySignatures] = await Promise.all([ - dirSignature(worktreesDir), - includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map() + const [worktreesDirSignature, primarySignatures, statusRefSignatures] = await Promise.all([ + gitCommonDirectorySignature(worktreesDir), + includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map(), + snapshotStatusRefSignatures(statusRefPaths) ]) // Why: enumerate the worktrees dir EVERY tick rather than gating the readdir on its stat signature. // A single readdir of a small dir is negligible next to the per-entry structural stats that already @@ -170,6 +120,8 @@ async function snapshotGitCommon( worktreesDirSignature, entries, primarySignatures, + statusRefPaths, + statusRefSignatures, didFullScan: forceFullScan } } @@ -250,6 +202,19 @@ function diffGitCommon( join(commonDirPath, name) ) ) + for (const path of next.statusRefPaths) { + // A newly selected ref is a baseline change, not a filesystem event. + if (!prev.statusRefPaths.has(path)) { + continue + } + const type = classifySignatureDiff( + prev.statusRefSignatures.get(path), + next.statusRefSignatures.get(path) + ) + if (type) { + events.push({ type, path }) + } + } return events } @@ -259,12 +224,19 @@ export async function startGitCommonPolling( pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, - includePrimary = true + includePrimary = true, + getStatusRefPaths: () => readonly string[] = () => [] ): Promise { let disposed = false let ticking = false let tickCount = 0 - let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary) + let snapshot = await snapshotGitCommon( + commonDirPath, + undefined, + includePrimary, + false, + new Set(getStatusRefPaths()) + ) let timer: ReturnType | null = null let parkedWhileHidden = false @@ -291,7 +263,8 @@ export async function startGitCommonPolling( commonDirPath, snapshot, includePrimary, - shouldForceFullScan + shouldForceFullScan, + new Set(getStatusRefPaths()) ) if (disposed) { return diff --git a/src/main/ipc/worktree-git-common-primary-polling.ts b/src/main/ipc/worktree-git-common-primary-polling.ts new file mode 100644 index 00000000000..336dbb0240f --- /dev/null +++ b/src/main/ipc/worktree-git-common-primary-polling.ts @@ -0,0 +1,148 @@ +import { stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { + WorktreeBasePollEvent, + WorktreeBaseSubscription, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' +import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-polling' + +type PrimaryMetadataSnapshot = { + signatures: Map + statusRefPaths: Set +} + +async function snapshotPrimaryCheckoutMetadata( + commonDirPath: string, + getStatusRefPaths: () => readonly string[] +): Promise { + const signatures = new Map() + const statusRefPaths = new Set(getStatusRefPaths()) + const paths = [ + ...PRIMARY_CHECKOUT_METADATA_FILES.map((name) => join(commonDirPath, name)), + ...statusRefPaths + ] + await Promise.all( + paths.map(async (filePath) => { + try { + const value = await stat(filePath) + if (value.isFile()) { + signatures.set(filePath, `${value.mtimeMs}:${value.ctimeMs}:${value.ino}:${value.size}`) + } + } catch { + // Missing dynamic leaves remain selected so later creation is visible. + } + }) + ) + return { signatures, statusRefPaths } +} + +function classifyMetadataSignatureDiff( + prevSignature: string | undefined, + nextSignature: string | undefined +): WorktreeBasePollEvent['type'] | null { + if (prevSignature === undefined && nextSignature === undefined) { + return null + } + if (prevSignature === undefined) { + return 'create' + } + if (nextSignature === undefined) { + return 'delete' + } + return prevSignature === nextSignature ? null : 'update' +} + +function diffPrimaryMetadata( + prev: PrimaryMetadataSnapshot, + next: PrimaryMetadataSnapshot +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + const paths = new Set([...prev.signatures.keys(), ...next.signatures.keys()]) + for (const path of paths) { + const isStatusRef = prev.statusRefPaths.has(path) || next.statusRefPaths.has(path) + if (isStatusRef && (!prev.statusRefPaths.has(path) || !next.statusRefPaths.has(path))) { + continue + } + const type = classifyMetadataSignatureDiff(prev.signatures.get(path), next.signatures.get(path)) + if (type) { + events.push({ type, path }) + } + } + return events +} + +export async function startGitCommonPrimaryPolling( + commonDirPath: string, + getStatusRefPaths: () => readonly string[], + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, + onFullScan?: () => void +): Promise { + let disposed = false + let ticking = false + let snapshot = await snapshotPrimaryCheckoutMetadata(commonDirPath, getStatusRefPaths) + let timer: ReturnType | null = null + let parkedWhileHidden = false + + const tick = async (): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { + return + } + ticking = true + const startedAt = Date.now() + onFullScan?.() + try { + const next = await snapshotPrimaryCheckoutMetadata(commonDirPath, getStatusRefPaths) + if (disposed) { + return + } + const events = diffPrimaryMetadata(snapshot, next) + snapshot = next + if (events.length > 0) { + onEvents(events) + } + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + void tick() + }) + timer = setTimeout(() => void tick(), pollIntervalMs) + timer.unref?.() + + return { + unsubscribe: async () => { + disposed = true + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() + } + } +} diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 6dab9765130..db9ebc1d5e8 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -119,13 +119,21 @@ describe('worktree git-common narrow watch (darwin)', () => { } } - async function startWatch(commonDir: string, received: WorktreeBasePollEvent[][]): Promise { + async function startWatch( + commonDir: string, + received: WorktreeBasePollEvent[][], + getStatusRefPaths: () => readonly string[] = () => [], + onWatchError?: (error: Error) => void + ): Promise { const watch = await startGitCommonWatch( makeTarget(commonDir), (events) => received.push(events), POLL_MS, 'darwin', - alwaysVisible + alwaysVisible, + undefined, + getStatusRefPaths, + onWatchError ) cleanups.push(() => watch.unsubscribe()) } @@ -146,6 +154,58 @@ describe('worktree git-common narrow watch (darwin)', () => { expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) }) + it('detects a common config write via the primary-metadata poll', async () => { + // Why: an external `git push -u` rewrites only the common config (plus + // remote-tracking refs) — outside the narrow worktrees/ stream, so the + // primary-metadata poll must carry it. + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const configPath = join(commonDir, 'config') + await writeFile(configPath, '[core]\n\tbare = false\n') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + await appendFile(configPath, '[branch "main"]\n\tremote = origin\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: configPath }) + }) + }) + + it('polls only the accepted upstream ref and rebinds without synthetic events', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const firstRef = join(commonDir, 'refs', 'remotes', 'origin', 'first') + const nextRef = join(commonDir, 'refs', 'remotes', 'origin', 'next') + const unrelatedRef = join(commonDir, 'refs', 'remotes', 'origin', 'unrelated') + await mkdir(join(commonDir, 'refs', 'remotes', 'origin'), { recursive: true }) + await Promise.all([ + writeFile(firstRef, 'aaa\n'), + writeFile(nextRef, 'bbb\n'), + writeFile(unrelatedRef, 'ccc\n') + ]) + let selected = [firstRef] + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received, () => selected) + + statCalls.length = 0 + await appendFile(firstRef, 'updated\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: firstRef }) + }) + expect(statCalls).not.toContain(unrelatedRef) + + received.length = 0 + selected = [nextRef] + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + expect(received.flat()).toEqual([]) + await appendFile(firstRef, 'ignored\n') + await appendFile(nextRef, 'watched\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: nextRef }) + }) + expect(received.flat()).not.toContainEqual({ type: 'update', path: firstRef }) + }) + it('tears down and re-arms when the watched root is deleted', async () => { installSubscribeMock() const commonDir = await makeCommonDir(true) @@ -199,6 +259,20 @@ describe('worktree git-common narrow watch (darwin)', () => { expect(childSubscriptions[1].unsubscribe).not.toHaveBeenCalled() }) + it('routes watcher failures through the unknown-metadata callback', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + const onWatchError = vi.fn() + await startWatch(commonDir, received, () => [], onWatchError) + + const error = new Error('watcher child reported failure') + childSubscriptions[0].callback(error, []) + + expect(onWatchError).toHaveBeenCalledWith(error) + expect(received).toEqual([]) + }) + it('reports a structural change after a watcher-child interruption', async () => { installSubscribeMock() const commonDir = await makeCommonDir(true) @@ -415,7 +489,8 @@ describe('worktree git-common polling gate (non-darwin)', () => { commonDir: string, received: WorktreeBasePollEvent[][], onFullScan?: () => void, - visibility: WorktreePollerWindowVisibility = alwaysVisible + visibility: WorktreePollerWindowVisibility = alwaysVisible, + getStatusRefPaths: () => readonly string[] = () => [] ): Promise { const watch = await startGitCommonWatch( makePollingTarget(commonDir), @@ -423,7 +498,8 @@ describe('worktree git-common polling gate (non-darwin)', () => { POLL_MS, 'linux', visibility, - onFullScan + onFullScan, + getStatusRefPaths ) cleanups.push(() => watch.unsubscribe()) } @@ -541,6 +617,47 @@ describe('worktree git-common polling gate (non-darwin)', () => { expect(fullScans).not.toHaveBeenCalled() }) + it('polls the common config so an external push -u surfaces its new upstream', async () => { + // Why: `git push -u` from an external shell rewrites only the common + // config (plus remote-tracking refs); the primary-metadata list must + // surface it or the upstream stays invisible until a safety poll. + const commonDir = await makePollingCommonDir() + const configPath = join(commonDir, 'config') + await writeFile(configPath, '[core]\n\tbare = false\n') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + await startPollingWatch(commonDir, received, fullScans) + + await appendFile(configPath, '[branch "main"]\n\tremote = origin\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: configPath }) + }) + expect(fullScans).not.toHaveBeenCalled() + }) + + it('detects create, update, and delete for one transient upstream ref', async () => { + const commonDir = await makePollingCommonDir() + const refPath = join(commonDir, 'refs', 'remotes', 'origin', 'feature', 'nested') + await mkdir(join(commonDir, 'refs', 'remotes', 'origin', 'feature'), { recursive: true }) + const received: WorktreeBasePollEvent[][] = [] + await startPollingWatch(commonDir, received, undefined, alwaysVisible, () => [refPath]) + + await writeFile(refPath, 'aaa\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'create', path: refPath }) + }) + received.length = 0 + await appendFile(refPath, 'bbb\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: refPath }) + }) + received.length = 0 + await rm(refPath) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'delete', path: refPath }) + }) + }) + it('forces a full scan on the 15-tick backstop', async () => { const commonDir = await makePollingCommonDir() const entry = join(commonDir, 'worktrees', 'backstop') diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 217e8ed308a..b60278f76b7 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -7,10 +7,8 @@ import type { WorktreeBaseSubscription, WorktreePollerWindowVisibility } from './worktree-base-directory-poller' -import { - PRIMARY_CHECKOUT_METADATA_FILES, - startGitCommonPolling -} from './worktree-git-common-polling' +import { startGitCommonPolling } from './worktree-git-common-polling' +import { startGitCommonPrimaryPolling } from './worktree-git-common-primary-polling' // Watches a repo's `/.git/worktrees` metadata plus the primary // checkout's shallow branch/index files — the only paths the git-common event @@ -27,130 +25,12 @@ import { // process when unsubscribe overlaps in-flight callbacks (issue #8732), and // root deletion via `git worktree prune` makes that overlap routine here. -// Why: branch switches and commits made in the primary checkout rewrite these -// top-level files (linked-worktree equivalents live under `worktrees/`). -// Deliberately excludes FETCH_HEAD-style churn that carries no status change. -async function snapshotPrimaryCheckoutMetadata( - commonDirPath: string -): Promise> { - const mtimes = new Map() - for (const name of PRIMARY_CHECKOUT_METADATA_FILES) { - const filePath = join(commonDirPath, name) - try { - mtimes.set(filePath, (await stat(filePath)).mtimeMs) - } catch { - // Missing file (e.g. no packed-refs yet) diffs into a create later. - } - } - return mtimes -} - -function diffMtimeMap( - prev: Map, - next: Map -): WorktreeBasePollEvent[] { - const events: WorktreeBasePollEvent[] = [] - for (const [path, mtime] of next) { - const prevMtime = prev.get(path) - if (prevMtime === undefined) { - events.push({ type: 'create', path }) - } else if (prevMtime !== mtime) { - events.push({ type: 'update', path }) - } - } - for (const path of prev.keys()) { - if (!next.has(path)) { - events.push({ type: 'delete', path }) - } - } - return events -} - -async function startSnapshotDiffPoller( - takeSnapshot: () => Promise>, - onEvents: (events: WorktreeBasePollEvent[]) => void, - pollIntervalMs: number, - visibility: WorktreePollerWindowVisibility, - onFullScan?: () => void -): Promise { - let disposed = false - let ticking = false - let snapshot = await takeSnapshot() - let timer: ReturnType | null = null - let parkedWhileHidden = false - - const tick = async (): Promise => { - timer = null - if (disposed) { - return - } - if (!visibility.isWindowVisible()) { - parkedWhileHidden = true - return - } - if (ticking) { - return - } - ticking = true - // Why: measure from tick start so cadence is start-to-start, not gap-after-completion (which would - // land each visible refresh a full scan-duration late every tick). - const startedAt = Date.now() - onFullScan?.() - try { - const next = await takeSnapshot() - if (disposed) { - return - } - const events = diffMtimeMap(snapshot, next) - snapshot = next - if (events.length > 0) { - onEvents(events) - } - } catch { - // Transient fs error: keep the previous snapshot and retry next tick. - } finally { - ticking = false - } - if (!disposed) { - // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would - // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for - // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. - const nextDelay = Math.max( - 0, - Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) - ) - timer = setTimeout(() => void tick(), nextDelay) - timer.unref?.() - } - } - - const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { - if (disposed || !parkedWhileHidden) { - return - } - parkedWhileHidden = false - void tick() - }) - - timer = setTimeout(() => void tick(), pollIntervalMs) - timer.unref?.() - - return { - unsubscribe: async () => { - disposed = true - if (timer) { - clearTimeout(timer) - } - unsubscribeVisibility() - } - } -} - async function startGitCommonNarrowWatch( target: WorktreeBaseWatchTarget, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, - visibility: WorktreePollerWindowVisibility + visibility: WorktreePollerWindowVisibility, + onWatchError?: (error: Error) => void ): Promise { const worktreesDir = join(target.path, 'worktrees') let disposed = false @@ -254,7 +134,11 @@ async function startGitCommonNarrowWatch( return } if (error) { - onEvents([{ type: 'update', path: worktreesDir }]) + if (onWatchError) { + onWatchError(error) + } else { + onEvents([{ type: 'update', path: worktreesDir }]) + } teardownAndRearm() return } @@ -274,7 +158,11 @@ async function startGitCommonNarrowWatch( // resubscribe gap; report a structural change so worktrees re-sync. onInterruption: () => { if (!disposed && active) { - onEvents([{ type: 'update', path: worktreesDir }]) + if (onWatchError) { + onWatchError(new Error('Git common watcher interrupted')) + } else { + onEvents([{ type: 'update', path: worktreesDir }]) + } } } } @@ -316,13 +204,16 @@ export async function startGitCommonWatch( pollIntervalMs: number, platform: NodeJS.Platform, visibility: WorktreePollerWindowVisibility, - onFullScan?: () => void + onFullScan?: () => void, + getStatusRefPaths: () => readonly string[] = () => [], + onWatchError?: (error: Error) => void ): Promise { if (platform === 'darwin') { const [narrowWatch, primaryMetadataPoll] = await Promise.all([ - startGitCommonNarrowWatch(target, onEvents, pollIntervalMs, visibility), - startSnapshotDiffPoller( - () => snapshotPrimaryCheckoutMetadata(target.path), + startGitCommonNarrowWatch(target, onEvents, pollIntervalMs, visibility, onWatchError), + startGitCommonPrimaryPolling( + target.path, + getStatusRefPaths, onEvents, pollIntervalMs, visibility, @@ -335,5 +226,13 @@ export async function startGitCommonWatch( } } } - return startGitCommonPolling(target.path, onEvents, pollIntervalMs, visibility, onFullScan) + return startGitCommonPolling( + target.path, + onEvents, + pollIntervalMs, + visibility, + onFullScan, + true, + getStatusRefPaths + ) } diff --git a/src/main/ipc/worktree-git-status-ref-watch.test.ts b/src/main/ipc/worktree-git-status-ref-watch.test.ts new file mode 100644 index 00000000000..62accf44432 --- /dev/null +++ b/src/main/ipc/worktree-git-status-ref-watch.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + clearActiveGitStatusRefBinding, + updateActiveGitStatusRefBinding, + type GitStatusRefBindingRequest, + type GitStatusRefWatchTarget +} from './worktree-git-status-ref-watch' + +function makeTarget(): GitStatusRefWatchTarget { + return { + kind: 'git-common', + path: 'C:\\repo\\.git', + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'repo', nestWorkspaces: false }]]), + gitStatusRefPaths: new Set() + } +} + +describe('worktree git status ref watch binding', () => { + afterEach(() => clearActiveGitStatusRefBinding()) + + function request(overrides: Partial = {}) { + return { + worktreeId: 'repo-1::C:\\repo', + worktreePath: 'C:\\repo', + executionHostId: 'local', + branch: 'refs/heads/feature/main', + upstreamName: 'origin/feature/main', + ...overrides + } + } + + it('binds a custom refspec destination exactly', async () => { + const target = makeTarget() + await updateActiveGitStatusRefBinding( + request(), + () => [target], + async () => 'refs/custom/origin/main' + ) + + expect([...target.gitStatusRefPaths]).toEqual(['C:/repo/.git/refs/custom/origin/main']) + }) + + it('does not spend the dynamic ref stat on a slash-named local upstream', async () => { + const target = makeTarget() + await updateActiveGitStatusRefBinding( + request(), + () => [target], + async () => 'refs/heads/feature/base' + ) + + expect(target.gitStatusRefPaths).toEqual(new Set()) + }) + + it('keeps a slash-containing remote name exact', async () => { + const target = makeTarget() + await updateActiveGitStatusRefBinding( + request(), + () => [target], + async () => 'refs/remotes/team/fork/feature/main' + ) + + expect([...target.gitStatusRefPaths]).toEqual([ + 'C:/repo/.git/refs/remotes/team/fork/feature/main' + ]) + }) + + it('does not resolve the exact ref again on unchanged status ticks', async () => { + const target = makeTarget() + const resolve = vi.fn(async () => 'refs/remotes/origin/feature/main') + + await updateActiveGitStatusRefBinding(request(), () => [target], resolve) + await updateActiveGitStatusRefBinding(request(), () => [target], resolve) + + expect(resolve).toHaveBeenCalledOnce() + }) + + it('applies an in-flight resolution to a replacement watch', async () => { + const first = makeTarget() + const replacement = makeTarget() + let current = [first] + let finish: (ref: string) => void = () => {} + const pending = new Promise((resolve) => { + finish = resolve + }) + + const update = updateActiveGitStatusRefBinding( + request(), + () => current, + () => pending + ) + current = [replacement] + finish('refs/remotes/origin/feature/main') + await update + + expect(first.gitStatusRefPaths).toEqual(new Set()) + expect([...replacement.gitStatusRefPaths]).toEqual([ + 'C:/repo/.git/refs/remotes/origin/feature/main' + ]) + + const latest = makeTarget() + current = [latest] + const resolve = vi.fn(async () => 'refs/remotes/origin/feature/main') + await updateActiveGitStatusRefBinding(request(), () => current, resolve) + expect(resolve).not.toHaveBeenCalled() + expect([...latest.gitStatusRefPaths]).toEqual(['C:/repo/.git/refs/remotes/origin/feature/main']) + }) +}) diff --git a/src/main/ipc/worktree-git-status-ref-watch.ts b/src/main/ipc/worktree-git-status-ref-watch.ts new file mode 100644 index 00000000000..f8035b0510f --- /dev/null +++ b/src/main/ipc/worktree-git-status-ref-watch.ts @@ -0,0 +1,198 @@ +import { resolveRuntimePath } from '../../shared/cross-platform-path' +import { isSafeGitStatusUpstreamRef } from '../../shared/git-status-upstream-ref' +import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { + pathRelativeToWorktreeWatchRoot, + type WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +export type GitStatusRefWatchTarget = Pick< + WorktreeBaseWatchTarget, + 'kind' | 'connectionId' | 'repos' | 'path' +> & { + gitStatusRefPaths: Set +} + +type ActiveGitStatusRefBinding = { + worktreeId: string + repoId: string + connectionId?: string + upstreamRef: string +} + +type GitStatusRefResolution = { + key: string + worktreeId: string + repoId: string + connectionId?: string + controller: AbortController + promise: Promise + retryAt: number +} + +export type GitStatusRefBindingRequest = { + worktreeId: string + worktreePath: string + executionHostId: string + connectionId?: string + providerGeneration?: number + branch?: string + upstreamName?: string +} + +const FAILED_RESOLUTION_RETRY_MS = 5 * 60_000 + +let activeBinding: ActiveGitStatusRefBinding | null = null +let activeResolution: GitStatusRefResolution | null = null + +function watchMatchesBinding( + watch: GitStatusRefWatchTarget, + binding: ActiveGitStatusRefBinding +): boolean { + return ( + watch.kind === 'git-common' && + watch.connectionId === binding.connectionId && + watch.repos.has(binding.repoId) + ) +} + +function watchMatchesResolution( + watch: GitStatusRefWatchTarget, + resolution: GitStatusRefResolution +): boolean { + return ( + watch.kind === 'git-common' && + watch.connectionId === resolution.connectionId && + watch.repos.has(resolution.repoId) + ) +} + +export function applyActiveGitStatusRefBinding(watch: GitStatusRefWatchTarget): void { + watch.gitStatusRefPaths.clear() + if (activeBinding && watchMatchesBinding(watch, activeBinding)) { + watch.gitStatusRefPaths.add(resolveRuntimePath(watch.path, activeBinding.upstreamRef)) + } +} + +function applyBindingToWatches(watches: Iterable): void { + for (const watch of watches) { + applyActiveGitStatusRefBinding(watch) + } +} + +function clearResolutionForWorktree( + worktreeId: string, + getWatches: () => Iterable +): void { + if (activeResolution?.worktreeId !== worktreeId) { + return + } + activeResolution.controller.abort() + activeResolution = null + activeBinding = null + applyBindingToWatches(getWatches()) +} + +function resolutionKey(args: GitStatusRefBindingRequest): string { + return [ + args.executionHostId, + args.connectionId ?? '', + args.providerGeneration ?? 0, + args.worktreeId, + args.worktreePath, + args.branch, + args.upstreamName + ].join('\0') +} + +export function updateActiveGitStatusRefBinding( + args: GitStatusRefBindingRequest, + getWatches: () => Iterable, + resolveUpstreamRef: (signal: AbortSignal) => Promise +): Promise { + if (!args.branch || !args.upstreamName) { + clearResolutionForWorktree(args.worktreeId, getWatches) + return Promise.resolve() + } + + const key = resolutionKey(args) + if (activeResolution?.key === key && activeResolution.retryAt > Date.now()) { + applyBindingToWatches(getWatches()) + return activeResolution.promise + } + + activeResolution?.controller.abort() + activeBinding = null + applyBindingToWatches(getWatches()) + + const controller = new AbortController() + const resolution = { + key, + worktreeId: args.worktreeId, + repoId: getRepoIdFromWorktreeId(args.worktreeId), + ...(args.connectionId ? { connectionId: args.connectionId } : {}), + controller, + promise: Promise.resolve(), + retryAt: Number.POSITIVE_INFINITY + } + resolution.promise = Promise.resolve() + .then(() => resolveUpstreamRef(controller.signal)) + .then((upstreamRef) => { + if (activeResolution !== resolution) { + return + } + activeBinding = + upstreamRef && isSafeGitStatusUpstreamRef(upstreamRef) + ? { + worktreeId: args.worktreeId, + repoId: getRepoIdFromWorktreeId(args.worktreeId), + ...(args.connectionId ? { connectionId: args.connectionId } : {}), + upstreamRef + } + : null + applyBindingToWatches(getWatches()) + }) + .catch(() => { + if (activeResolution === resolution && !controller.signal.aborted) { + resolution.retryAt = Date.now() + FAILED_RESOLUTION_RETRY_MS + } + }) + activeResolution = resolution + return resolution.promise +} + +export function invalidateActiveGitStatusRefResolution( + changedWatch: GitStatusRefWatchTarget, + getWatches: () => Iterable +): void { + if (!activeResolution || !watchMatchesResolution(changedWatch, activeResolution)) { + return + } + activeResolution.controller.abort() + activeResolution = null + activeBinding = null + applyBindingToWatches(getWatches()) +} + +export function invalidateGitStatusRefResolutionForPaths( + changedWatch: GitStatusRefWatchTarget, + paths: (string | undefined)[], + getWatches: () => Iterable +): void { + const configChanged = paths.some((path) => { + if (!path || changedWatch.kind !== 'git-common') { + return false + } + const parts = pathRelativeToWorktreeWatchRoot(changedWatch.path, path) + return parts?.length === 1 && parts[0] === 'config' + }) + if (configChanged) { + invalidateActiveGitStatusRefResolution(changedWatch, getWatches) + } +} + +export function clearActiveGitStatusRefBinding(): void { + activeResolution?.controller.abort() + activeResolution = null + activeBinding = null +} diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index c83eece63b6..f5925e1acc1 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' import * as localWorktreeFilesystem from '../local-worktree-filesystem' const ORIGINAL_PLATFORM = process.platform @@ -2589,6 +2589,139 @@ describe('registerWorktreeHandlers', () => { }) }) + it('lists every persisted SSH worktree without accessing the live provider', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const metaById = { + 'repo-1::/remote/repo': makeWorktreeMeta({ + displayName: 'main', + hostId: sshHostId + }), + 'repo-1::/remote/queued': makeWorktreeMeta({ + displayName: 'queued', + hostId: sshHostId + }), + 'repo-1::/remote/other-host': makeWorktreeMeta({ + displayName: 'other host', + hostId: toSshExecutionHostId('target-b') + }) + } + store.getRepos.mockReturnValue([sshRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toMatchObject({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: sshHostId, + result: { + repoId: sshRepo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [ + expect.objectContaining({ path: '/remote/repo', isMainWorktree: true }), + expect.objectContaining({ path: '/remote/queued', isMainWorktree: false }) + ] + } + }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('lists SSH folder workspaces at the folder path, not the instance-suffixed id', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const folderRepo = { + id: 'repo-1', + path: '/remote/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a', + kind: 'folder' as const + } + const rootId = `${folderRepo.id}::${folderRepo.path}` + const instanceId = `${rootId}::workspace:11111111-2222-3333-4444-555555555555` + const metaById = { + [rootId]: makeWorktreeMeta({ displayName: 'root', hostId: sshHostId }), + [instanceId]: makeWorktreeMeta({ + displayName: 'second workspace', + hostId: sshHostId, + instanceId: '11111111-2222-3333-4444-555555555555' + }) + } + store.getRepos.mockReturnValue([folderRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = (await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: folderRepo.id, + executionHostId: sshHostId + })) as { status: string; result: { authoritative: boolean; worktrees: Worktree[] } } + + expect(result.status).toBe('complete') + expect(result.result.authoritative).toBe(false) + // Why: the git-worktree synthesizer would read the "::workspace:" tail as a directory. + expect(result.result.worktrees).toEqual([ + expect.objectContaining({ id: rootId, path: folderRepo.path, isMainWorktree: true }), + expect.objectContaining({ id: instanceId, path: folderRepo.path, isMainWorktree: false }) + ]) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('rejects metadata-only reads for non-SSH execution hosts', async () => { + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + expect(store.getRepos).not.toHaveBeenCalled() + }) + + it('rejects ambiguous metadata-only SSH owners', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo-a', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo, { ...sshRepo, path: '/remote/repo-b' }]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: sshRepo.id, + executionHostId: sshHostId + }) + expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { const sshRepo = { id: 'shared-repo', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c8dceec6bed..098e1e8191f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -49,7 +49,9 @@ import { import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, type DirectSshDetectedWorktreeRequest, + type HostQualifiedKnownWorktreeResult, type HostQualifiedDetectedWorktreeResult, + type ListKnownWorktreesForExecutionHostArgs, type ListDetectedWorktreesArgs, type ProviderRequestId } from '../../shared/detected-worktree-provider-contract' @@ -229,7 +231,8 @@ import { import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - getRepoIdFromWorktreeId + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId } from '../../shared/worktree-id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { @@ -804,6 +807,17 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt return index } +// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole +// store, so this is cheaper per call than the unfiltered index, not free. +function createSshWorktreeMetaIndexForRepo( + allMeta: Record, + repoId: string +): SshWorktreeMetaIndex { + return createSshWorktreeMetaIndex( + Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) + ) +} + function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo { return { path, @@ -842,10 +856,15 @@ function listDisconnectedSshWorktrees( if (Object.keys(ownershipUpdates).length > 0) { store.setWorktreeMeta(candidate.id, ownershipUpdates) } + // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() + // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must + // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic + // basename instead. const worktree = mergeWorktree( repo.id, synthesizeSshGitWorktree(repo, candidate.path, meta), - meta + meta, + getWorktreePathBasenameFromId(candidate.id) ?? undefined ) byWorktreeId.delete(worktree.id) byWorktreeId.set(worktree.id, worktree) @@ -1826,6 +1845,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:listAll') ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:listDetected') + ipcMain.removeHandler('worktrees:listKnownForExecutionHost') ipcMain.removeHandler('worktrees:cancelListDetected') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:prefetchCreateBase') @@ -1970,6 +1990,66 @@ export function registerWorktreeHandlers( } }) + ipcMain.handle( + 'worktrees:listKnownForExecutionHost', + (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { + // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — + // it owns no repo, so every guard below still rejects it. + const requestedRepoId = args?.repoId ?? '' + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const rejected = (): HostQualifiedKnownWorktreeResult => ({ + status: 'rejected', + repoId: requestedRepoId, + executionHostId: requestedExecutionHostId + }) + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh') { + return rejected() + } + // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the + // whole catalog, so a separate pass here is pure cost. + const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return rejected() + } + const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ + status: 'complete', + repoId: repo.id, + executionHostId: requestedExecutionHostId, + result: { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees + } + }) + // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. + if (isFolderRepo(repo)) { + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) + ? rejected() + : complete( + // Why: match the authoritative folder listing; without lineage these rows render flat and then + // reshuffle once the real scan lands. + projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + ) + } + const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) + return complete( + buildDisconnectedDetectedWorktrees( + store, + repo, + listDisconnectedSshWorktrees(store, repo, metaIndex) + ) + ) + } + ) + ipcMain.handle( 'worktrees:listDetected', async ( diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 81faa5f6297..24cc6e512cc 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -676,7 +676,6 @@ describe('Store', () => { const settings = store.getSettings() expect(settings.branchPrefix).toBe('git-username') expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false) - expect(settings.sourceControlGroupOrder).toBe('changes-first') expect(settings.theme).toBe('system') expect(settings.appIcon).toBe('classic') expect(settings.appFontFamily).toBe('Geist') @@ -2784,21 +2783,6 @@ describe('Store', () => { expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first') }) - it('normalizes malformed source control group order on load', async () => { - writeDataFile({ - schemaVersion: 1, - repos: [], - worktreeMeta: {}, - settings: { sourceControlGroupOrder: 'tracked-first' }, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - const store = await createStore() - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - }) - it('repairs drifted task provider defaults on load', async () => { writeDataFile({ schemaVersion: 1, @@ -5817,15 +5801,21 @@ describe('Store', () => { expect(store.getSettings().sourceControlViewMode).toBe('tree') }) - it('updateSettings persists sourceControlGroupOrder as a user setting', async () => { + it('drops retired Source Control order preferences', async () => { + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + settings: { + ...getDefaultPersistedState(testState.dir).settings, + sourceControlGroupOrder: 'changes-first', + sourceControlHierarchyDefaultedV2: true + } + } as never) + const store = await createStore() - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - - store.updateSettings({ sourceControlGroupOrder: 'staged-first' }) - expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first') - - store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never }) - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings).not.toHaveProperty('sourceControlGroupOrder') + expect(persisted.settings).not.toHaveProperty('sourceControlHierarchyDefaultedV2') }) it('updateSettings normalizes terminal shortcut policy', async () => { @@ -5883,18 +5873,15 @@ describe('Store', () => { const store = await createStore() expect(store.getSettings().sourceControlViewMode).toBe('list') - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - - store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' }) + store.updateSettings({ sourceControlViewMode: 'tree' }) store.flush() const persisted = readDataFile() as { - settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string } + settings?: { sourceControlViewMode?: string } workspaceSession?: typeof workspaceSession worktreeMeta?: Record } expect(persisted.settings?.sourceControlViewMode).toBe('tree') - expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first') expect(persisted.workspaceSession).toEqual({ ...getDefaultWorkspaceSession(), ...workspaceSession @@ -5906,13 +5893,9 @@ describe('Store', () => { expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([ 'settings.sourceControlViewMode' ]) - expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([ - 'settings.sourceControlGroupOrder' - ]) const reloaded = await createStore() expect(reloaded.getSettings().sourceControlViewMode).toBe('tree') - expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first') expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a') }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 668b2b4d27f..af82bcbdd89 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -182,7 +182,6 @@ import { } from '../shared/mobile-pairing-custom-address' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' -import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order' import { normalizeAppIconId } from '../shared/app-icon' import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' import { @@ -650,12 +649,22 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal : {} } -function stripLegacyTerminalScrollbackBytes( +function stripRetiredSettingsFields( settings: Partial | undefined ): Partial { - const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ?? - {}) as Partial & { terminalScrollbackBytes?: unknown } + const { + terminalScrollbackBytes: _legacyScrollbackBytes, + sourceControlGroupOrder: _sourceControlGroupOrder, + sourceControlHierarchyDefaultedV2: _sourceControlHierarchyDefaultedV2, + ...rest + } = (settings ?? {}) as Partial & { + terminalScrollbackBytes?: unknown + sourceControlGroupOrder?: unknown + sourceControlHierarchyDefaultedV2?: unknown + } void _legacyScrollbackBytes + void _sourceControlGroupOrder + void _sourceControlHierarchyDefaultedV2 return rest } @@ -3316,15 +3325,6 @@ export class Store { ) { this.loadNeedsSave = true } - const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( - parsed.settings?.sourceControlGroupOrder - ) - if ( - parsed.settings?.sourceControlGroupOrder !== undefined && - parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder - ) { - this.loadNeedsSave = true - } result = { ...defaults, ...parsed, @@ -3346,7 +3346,7 @@ export class Store { settings: { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripLegacyTerminalScrollbackBytes(parsed.settings), + ...stripRetiredSettingsFields(parsed.settings), prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), @@ -3420,7 +3420,6 @@ export class Store { }), notifications: normalizeNotificationSettings(parsed.settings?.notifications), sourceControlAi: migratedSourceControlAi, - sourceControlGroupOrder: normalizedSourceControlGroupOrder, // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( migratedSourceControlAi, @@ -5691,7 +5690,7 @@ export class Store { updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) + const sanitizedUpdates = stripRetiredSettingsFields(updates) // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true @@ -5759,11 +5758,6 @@ export class Store { updates.terminalShortcutPolicy ) } - if ('sourceControlGroupOrder' in updates) { - sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder( - updates.sourceControlGroupOrder - ) - } if ('appIcon' in updates) { sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon) } @@ -7075,6 +7069,13 @@ export class Store { } } + // Why no write of its own: the committed quit path calls this immediately before the final store + // flush, and that flush is what persists it. A durable write here would race the flush and be + // rejected the moment it latches, which is exactly how an attached lease used to survive quit. + markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { + this.updateSshRemotePtyLeaseStates(targetId, state) + } + async markSshRemotePtyLeasesAsync( targetId: string, state: SshRemotePtyLease['state'] diff --git a/src/main/providers/git-provider-status-options.ts b/src/main/providers/git-provider-status-options.ts index 3d03ca6a269..a8924924f22 100644 --- a/src/main/providers/git-provider-status-options.ts +++ b/src/main/providers/git-provider-status-options.ts @@ -3,5 +3,7 @@ export type GitProviderStatusOptions = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string signal?: AbortSignal } diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index 0527922f4cc..bc812e1a46a 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -157,10 +157,9 @@ describe('spawnShellWithFallback macOS TCC login wrapping', () => { existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue(dirStats(true)) accessSyncMock.mockReturnValue(undefined) - // Emulate the real wrapper: prepend /usr/bin/login in front of the shell. wrapSpawnMock.mockImplementation((file: string, args: string[]) => ({ - file: '/usr/bin/login', - args: ['-flpq', 'ada', file, ...args] + file: '/wrapped/login', + args: ['wrapped', file, ...args] })) }) @@ -186,8 +185,8 @@ describe('spawnShellWithFallback macOS TCC login wrapping', () => { expect(wrapSpawnMock).toHaveBeenCalledWith('/bin/zsh', ['-l'], expect.any(Object)) expect(ptySpawn).toHaveBeenCalledWith( - '/usr/bin/login', - ['-flpq', 'ada', '/bin/zsh', '-l'], + '/wrapped/login', + ['wrapped', '/bin/zsh', '-l'], expect.objectContaining({ cwd: '/work', cols: 80, rows: 24 }) ) // The reported shellPath stays the real shell so identity/name logic is intact. @@ -220,8 +219,8 @@ describe('spawnShellWithFallback macOS TCC login wrapping', () => { expect.objectContaining({ SHELL: '/bin/bash' }) ) expect(ptySpawn).toHaveBeenLastCalledWith( - '/usr/bin/login', - ['-flpq', 'ada', '/bin/bash', '-l'], + '/wrapped/login', + ['wrapped', '/bin/bash', '-l'], expect.objectContaining({ cwd: '/work' }) ) expect(result.shellPath).toBe('/bin/bash') diff --git a/src/main/providers/macos-tcc-login-shell.test.ts b/src/main/providers/macos-tcc-login-shell.test.ts index 9ea2ff22a06..c27a3f509dc 100644 --- a/src/main/providers/macos-tcc-login-shell.test.ts +++ b/src/main/providers/macos-tcc-login-shell.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnSync } from 'node:child_process' +import type * as ChildProcess from 'node:child_process' import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe' const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock, ptyProbeMock } = vi.hoisted( @@ -13,7 +15,10 @@ const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock, ptyProbeMock } vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) vi.mock('node:os', () => ({ userInfo: userInfoMock })) -vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) vi.mock('./macos-login-session-pty-probe', async (importOriginal) => ({ ...(await importOriginal()), runMacosLoginSessionPtyProbe: ptyProbeMock @@ -29,6 +34,7 @@ import { type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const +const itOnPosixHost = process.platform === 'win32' ? it.skip : it describe('wrapShellSpawnForMacosTccAttribution', () => { let origPlatform: PropertyDescriptor | undefined @@ -67,12 +73,25 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { vi.clearAllMocks() }) - it('wraps the shell in /usr/bin/login on macOS, preserving the shell args behind it', async () => { + it('uses the exact positional login-shell trampoline argv on macOS', async () => { setPlatform('darwin') await prepareMacosTccLoginShell() expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ file: '/usr/bin/login', - args: ['-flpq', 'ada', '/usr/bin/env', 'SHELL=/bin/zsh', '/bin/zsh', '-l'] + args: [ + '-flpq', + 'ada', + '/bin/bash', + '--noprofile', + '--norc', + '-p', + '-c', + 'export SHELL="$1"; shift; exec -l -- "$@"', + 'orca-tcc-login', + '/bin/zsh', + '/bin/zsh', + '-l' + ] }) expect(execFileMock).toHaveBeenCalledWith( '/usr/bin/login', @@ -328,7 +347,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { expect(execFileMock).toHaveBeenCalledTimes(1) }) - it('keeps bash rcfile args intact after the shell path', async () => { + it('preserves the non-login Bash shell-ready rcfile launch', async () => { setPlatform('darwin') await prepareMacosTccLoginShell() expect( @@ -338,8 +357,14 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { args: [ '-flpq', 'ada', - '/usr/bin/env', - 'SHELL=/bin/bash', + '/bin/bash', + '--noprofile', + '--norc', + '-p', + '-c', + 'export SHELL="$1"; shift; exec -- "$@"', + 'orca-tcc-login', + '/bin/bash', '/bin/bash', '--rcfile', '/orca/bash/rcfile' @@ -354,7 +379,20 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '/opt/homebrew/bin/fish' }) ).toEqual({ file: '/usr/bin/login', - args: ['-flpq', 'ada', '/usr/bin/env', 'SHELL=/opt/homebrew/bin/fish', '/bin/zsh', '-l'] + args: [ + '-flpq', + 'ada', + '/bin/bash', + '--noprofile', + '--norc', + '-p', + '-c', + 'export SHELL="$1"; shift; exec -l -- "$@"', + 'orca-tcc-login', + '/opt/homebrew/bin/fish', + '/bin/zsh', + '-l' + ] }) }) @@ -363,29 +401,75 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { await prepareMacosTccLoginShell() expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '' })).toEqual({ file: '/usr/bin/login', - args: ['-flpq', 'ada', '/usr/bin/env', 'SHELL=/bin/zsh', '/bin/zsh', '-l'] + args: [ + '-flpq', + 'ada', + '/bin/bash', + '--noprofile', + '--norc', + '-p', + '-c', + 'export SHELL="$1"; shift; exec -l -- "$@"', + 'orca-tcc-login', + '/bin/zsh', + '/bin/zsh', + '-l' + ] }) }) - it('skips the env(1) interposition when the shell path would parse as an assignment', async () => { + it('passes spaces and equals signs positionally without interpolation', async () => { setPlatform('darwin') await prepareMacosTccLoginShell() - expect(wrapShellSpawnForMacosTccAttribution('/odd=dir/zsh', ['-l'])).toEqual({ + expect( + wrapShellSpawnForMacosTccAttribution( + '/Applications/Custom Shell/bin/fish=debug', + ['--init-command', 'set label=a b'], + { SHELL: '/Applications/Custom Shell/bin/fish=debug' } + ) + ).toEqual({ file: '/usr/bin/login', - args: ['-flpq', 'ada', '/odd=dir/zsh', '-l'] + args: [ + '-flpq', + 'ada', + '/bin/bash', + '--noprofile', + '--norc', + '-p', + '-c', + 'export SHELL="$1"; shift; exec -l -- "$@"', + 'orca-tcc-login', + '/Applications/Custom Shell/bin/fish=debug', + '/Applications/Custom Shell/bin/fish=debug', + '--init-command', + 'set label=a b' + ] }) }) - it('still wraps with login when /usr/bin/env is missing, without interposition', async () => { + it('terminates exec option parsing before a dash-prefixed shell name', async () => { setPlatform('darwin') - existsSyncMock.mockImplementation( - (path: string) => path === '/usr/bin/login' || path === '/usr/bin/printf' + await prepareMacosTccLoginShell() + const wrapped = wrapShellSpawnForMacosTccAttribution('-custom-shell', ['-l']) + + expect(wrapped.args).toContain('export SHELL="$1"; shift; exec -l -- "$@"') + expect(wrapped.args.slice(-2)).toEqual(['-custom-shell', '-l']) + }) + + itOnPosixHost('ignores inherited BASH_ENV before evaluating the trampoline', () => { + const result = spawnSync( + '/bin/bash', + ['--noprofile', '--norc', '-p', '-c', 'printf "TRAMPOLINE_OK\\n"'], + { + encoding: 'utf8', + env: { ...process.env, BASH_ENV: '/dev/stdin' }, + input: 'printf "BASH_ENV_EXECUTED\\n"\n' + } ) - await prepareMacosTccLoginShell() - expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({ - file: '/usr/bin/login', - args: ['-flpq', 'ada', '/bin/zsh', '-l'] - }) + + expect(result.status).toBe(0) + expect(result.stdout).toBe('TRAMPOLINE_OK\n') + expect(result.stderr).toBe('') }) it('is a no-op on non-macOS platforms', () => { diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts index 211b0ad276d..463ba1fb3b0 100644 --- a/src/main/providers/macos-tcc-login-shell.ts +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' import { userInfo } from 'node:os' +import { basename } from 'node:path' import { classifyLoginPreflightError, runMacosLoginSessionPtyProbe, @@ -10,8 +11,10 @@ import { export type { LoginPreflightOutcome } from './macos-login-session-pty-probe' const MACOS_LOGIN_PATH = '/usr/bin/login' -const MACOS_ENV_PATH = '/usr/bin/env' +const MACOS_BASH_PATH = '/bin/bash' const MACOS_PRINTF_PATH = '/usr/bin/printf' +const LOGIN_SHELL_TRAMPOLINE = 'export SHELL="$1"; shift; exec -l -- "$@"' +const DIRECT_SHELL_TRAMPOLINE = 'export SHELL="$1"; shift; exec -- "$@"' const LOGIN_PREFLIGHT_TIMEOUT_MS = 500 // Why: the death-watch probe runs off the spawn path, so it can afford a bound // that outlasts a PAM stack answering slowly rather than misreading it as a hang. @@ -306,11 +309,11 @@ export async function probeMacosLoginSessionAlive( * Wrap a macOS shell spawn in `/usr/bin/login -flpq …` so terminal children * get their own TCC identity instead of collapsing into Orca's bundle id — signed * CLIs like `op` otherwise re-prompt every launch because tccd attributes the grant - * to Orca and never persists it (#6996). This mirrors how Terminal.app spawns shells. + * to Orca and never persists it (#6996, #8985). * - * Why the env(1) interposition: login(1) overwrites SHELL from the account DB even - * under -p, so `/usr/bin/env SHELL=` re-asserts the shell Orca actually runs - * without disturbing login's attribution (skipped when the shell path contains `=`). + * A clean bash trampoline restores SHELL after login(1) overwrites it, then replaces + * itself with the configured shell. Values stay positional so custom paths and + * arguments are never interpreted as shell source. * * No-op off macOS, when already wrapped, when disabled via {@link DISABLE_ENV_VAR}, * or when the login(1) PAM preflight rejects this process's user. @@ -346,13 +349,29 @@ export function wrapShellSpawnForMacosTccAttribution( } const shellEnvValue = env?.SHELL || file - const interposedShellEnv = - !file.includes('=') && existsSync(MACOS_ENV_PATH) - ? [MACOS_ENV_PATH, `SHELL=${shellEnvValue}`] - : [] + // Why: Bash ignores --rcfile when argv[0] marks it as a login shell; Orca's + // rcfile already reproduces login startup and must remain the active wrapper. + const trampoline = + basename(file).toLowerCase() === 'bash' && args.includes('--rcfile') + ? DIRECT_SHELL_TRAMPOLINE + : LOGIN_SHELL_TRAMPOLINE + // Why: -p blocks login(1)-preserved BASH_ENV and imported functions before the fixed trampoline runs. return { file: MACOS_LOGIN_PATH, - args: ['-flpq', username, ...interposedShellEnv, file, ...args] + args: [ + '-flpq', + username, + MACOS_BASH_PATH, + '--noprofile', + '--norc', + '-p', + '-c', + trampoline, + 'orca-tcc-login', + shellEnvValue, + file, + ...args + ] } } diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index b66b5633503..baaaed69406 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SshFilesystemProvider } from './ssh-filesystem-provider' +import { SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS } from '../ssh/ssh-file-stream-inactivity-deadline' +import { publishSystemResume, publishSystemSuspend } from '../system-power-lifecycle' type MockMultiplexer = { request: ReturnType @@ -10,10 +12,12 @@ type MockMultiplexer = { dispose: ReturnType isDisposed: ReturnType _emitMethod: (method: string, params: Record) => void + _listenerCount: () => number } function createMockMux(): MockMultiplexer { const methodHandlers = new Map) => void>>() + const disposeHandlers = new Set<(reason: 'shutdown' | 'connection_lost') => void>() return { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn(), @@ -29,7 +33,12 @@ function createMockMux(): MockMultiplexer { return () => set!.delete(handler) } ), - onDispose: vi.fn(() => () => {}), + onDispose: vi.fn((handler: (reason: 'shutdown' | 'connection_lost') => void) => { + disposeHandlers.add(handler) + return () => { + disposeHandlers.delete(handler) + } + }), dispose: vi.fn(), isDisposed: vi.fn().mockReturnValue(false), _emitMethod: (method, params) => { @@ -39,7 +48,10 @@ function createMockMux(): MockMultiplexer { handler(params) } } - } + }, + _listenerCount: () => + disposeHandlers.size + + [...methodHandlers.values()].reduce((count, handlers) => count + handlers.size, 0) } } @@ -48,10 +60,15 @@ describe('SshFilesystemProvider readFile streaming', () => { let provider: SshFilesystemProvider beforeEach(() => { + publishSystemResume() mux = createMockMux() provider = new SshFilesystemProvider('conn-1', mux as never) }) + afterEach(() => { + vi.useRealTimers() + }) + it('streams via fs.readFileStream and reassembles utf-8 text', async () => { const text = 'hello world' const totalSize = Buffer.byteLength(text, 'utf-8') @@ -159,6 +176,123 @@ describe('SshFilesystemProvider readFile streaming', () => { await expect(provider.readFile('/home/x.txt')).rejects.toThrow(/gone/) }) + it('cancels and cleans up a stream that stalls after metadata', async () => { + vi.useFakeTimers() + mux.request.mockResolvedValue({ + streamId: 9, + totalSize: 1, + isBinary: false, + resultEncoding: 'utf-8' + }) + + const read = provider.readFile('/home/x.txt') + const rejection = expect(read).rejects.toThrow(/file stream stalled/i) + await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS) + + await rejection + expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 9 }) + expect(mux._listenerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a long stream alive while chunks continue arriving', async () => { + vi.useFakeTimers() + const chunkSize = 256 * 1024 + mux.request.mockResolvedValue({ + streamId: 10, + totalSize: chunkSize + 1, + isBinary: true, + resultEncoding: 'base64' + }) + + const read = provider.readFile('/home/x.bin') + await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) + mux._emitMethod('fs.streamChunk', { + streamId: 10, + seq: 0, + data: Buffer.alloc(chunkSize).toString('base64') + }) + await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) + mux._emitMethod('fs.streamChunk', { + streamId: 10, + seq: 1, + data: Buffer.alloc(1).toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 10 }) + + await expect(read).resolves.toEqual({ + content: Buffer.alloc(chunkSize + 1).toString('base64'), + isBinary: true + }) + expect(mux.notify).not.toHaveBeenCalledWith('fs.cancelStream', expect.anything()) + expect(mux._listenerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('grants an active stream a fresh inactivity window after system resume', async () => { + vi.useFakeTimers() + mux.request.mockResolvedValue({ + streamId: 11, + totalSize: 1, + isBinary: false, + resultEncoding: 'utf-8' + }) + + const read = provider.readFile('/home/x.txt') + await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) + publishSystemSuspend() + vi.setSystemTime(Date.now() + 60 * 60_000) + publishSystemResume() + await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) + + expect(mux.notify).not.toHaveBeenCalledWith('fs.cancelStream', expect.anything()) + mux._emitMethod('fs.streamChunk', { + streamId: 11, + seq: 0, + data: Buffer.from('x').toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 11 }) + await expect(read).resolves.toEqual({ content: 'x', isBinary: false }) + + publishSystemResume() + expect(mux._listenerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps metadata received during suspend paused until resume', async () => { + vi.useFakeTimers() + mux.request.mockResolvedValue({ + streamId: 12, + totalSize: 1, + isBinary: false, + resultEncoding: 'utf-8' + }) + + publishSystemSuspend() + const read = provider.readFile('/home/x.txt') + const outcome = vi.fn() + void read.then( + () => outcome('resolved'), + () => outcome('rejected') + ) + await vi.advanceTimersByTimeAsync(60 * 60_000) + + expect(outcome).not.toHaveBeenCalled() + expect(mux.notify).not.toHaveBeenCalledWith('fs.cancelStream', expect.anything()) + publishSystemResume() + await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) + mux._emitMethod('fs.streamChunk', { + streamId: 12, + seq: 0, + data: Buffer.from('x').toString('base64') + }) + mux._emitMethod('fs.streamEnd', { streamId: 12 }) + + await expect(read).resolves.toEqual({ content: 'x', isBinary: false }) + expect(mux._listenerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + it('rejects on chunk count mismatch at streamEnd', async () => { const totalSize = 256 * 1024 * 3 mux.request.mockImplementation(async () => { diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 9974669fbcd..c0613f35f2e 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -278,13 +278,17 @@ describe('SshGitProvider', () => { const result = await provider.execNonInteractive('pnpm', ['--version'], '/home/user/repo', 8000) - expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', { - binary: 'pnpm', - args: ['--version'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 8000 - }) + expect(mux.request).toHaveBeenCalledWith( + 'agent.execNonInteractive', + { + binary: 'pnpm', + args: ['--version'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 8000 + }, + { timeoutMs: 13_000 } + ) expect(result).toEqual(execResult) }) @@ -309,17 +313,21 @@ describe('SshGitProvider', () => { } ) - expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', { - binary: '/bin/bash', - args: ['-lc', 'echo "$ORCA_WORKTREE_PATH"'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 120_000, - env: { - ORCA_ROOT_PATH: '/home/user/repo', - ORCA_WORKTREE_PATH: '/home/user/repo-feature' - } - }) + expect(mux.request).toHaveBeenCalledWith( + 'agent.execNonInteractive', + { + binary: '/bin/bash', + args: ['-lc', 'echo "$ORCA_WORKTREE_PATH"'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 120_000, + env: { + ORCA_ROOT_PATH: '/home/user/repo', + ORCA_WORKTREE_PATH: '/home/user/repo-feature' + } + }, + { timeoutMs: 125_000 } + ) }) it('cancelNonInteractiveExec sends best-effort relay cancellation', async () => { @@ -432,6 +440,65 @@ describe('SshGitProvider', () => { ) }) + it('keeps the transport alive for an agent response beyond the default request timeout', async () => { + vi.useFakeTimers() + try { + const execResult = { + stdout: 'Update docs', + stderr: '', + exitCode: 0, + timedOut: false + } + mux.request.mockImplementation((_method, _payload, options) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('transport request timed out')), + options?.timeoutMs ?? 30_000 + ) + setTimeout(() => { + clearTimeout(timeout) + resolve(execResult) + }, 45_000) + }) + }) + + let state: 'pending' | 'resolved' | 'rejected' = 'pending' + const pending = provider + .executeCommitMessagePlan( + { + binary: 'codex', + args: ['exec', 'PROMPT'], + stdinPayload: null, + label: 'Codex' + }, + '/home/user/repo', + 60_000 + ) + .then( + (result) => { + state = 'resolved' + return result + }, + (error) => { + state = 'rejected' + throw error + } + ) + void pending.catch(() => {}) + + await vi.advanceTimersByTimeAsync(30_000) + expect(state).toBe('pending') + await vi.advanceTimersByTimeAsync(15_000) + + await expect(pending).resolves.toEqual(execResult) + expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', expect.any(Object), { + timeoutMs: 65_000 + }) + } finally { + vi.useRealTimers() + } + }) + it('executeCommitMessagePlan delegates the prepared plan to the relay', async () => { const execResult = { stdout: 'Update docs', @@ -452,14 +519,18 @@ describe('SshGitProvider', () => { 60_000 ) - expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', { - binary: 'codex', - args: ['exec', 'PROMPT'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 60_000, - operation: 'commit-message' - }) + expect(mux.request).toHaveBeenCalledWith( + 'agent.execNonInteractive', + { + binary: 'codex', + args: ['exec', 'PROMPT'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 60_000, + operation: 'commit-message' + }, + { timeoutMs: 65_000 } + ) expect(result).toEqual(execResult) }) @@ -496,22 +567,32 @@ describe('SshGitProvider', () => { ) await waitForRequestCount(mux.request, 2) - expect(mux.request).toHaveBeenNthCalledWith(1, 'agent.execNonInteractive', { - binary: 'codex', - args: ['exec', 'PROMPT'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 60_000, - operation: 'commit-message' - }) - expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.execNonInteractive', { - binary: 'codex', - args: ['exec', 'PROMPT'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 60_000, - operation: 'pull-request-fields' - }) + expect(mux.request).toHaveBeenNthCalledWith( + 1, + 'agent.execNonInteractive', + { + binary: 'codex', + args: ['exec', 'PROMPT'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 60_000, + operation: 'commit-message' + }, + { timeoutMs: 65_000 } + ) + expect(mux.request).toHaveBeenNthCalledWith( + 2, + 'agent.execNonInteractive', + { + binary: 'codex', + args: ['exec', 'PROMPT'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 60_000, + operation: 'pull-request-fields' + }, + { timeoutMs: 65_000 } + ) await provider.cancelGenerateCommitMessage('/home/user/repo') await provider.cancelGenerateCommitMessage('/home/user/repo', 'pull-request-fields') @@ -556,13 +637,18 @@ describe('SshGitProvider', () => { await first await waitForRequestCount(mux.request, 2) - expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.execNonInteractive', { - binary: 'pnpm', - args: ['install'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 8000 - }) + expect(mux.request).toHaveBeenNthCalledWith( + 2, + 'agent.execNonInteractive', + { + binary: 'pnpm', + args: ['install'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 8000 + }, + { timeoutMs: 13_000 } + ) completeRequests.shift()?.() await second }) @@ -637,13 +723,18 @@ describe('SshGitProvider', () => { completeRequests.shift()?.() await first await waitForRequestCount(mux.request, 3) - expect(mux.request).toHaveBeenNthCalledWith(3, 'agent.execNonInteractive', { - binary: 'pnpm', - args: ['install'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 8000 - }) + expect(mux.request).toHaveBeenNthCalledWith( + 3, + 'agent.execNonInteractive', + { + binary: 'pnpm', + args: ['install'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 8000 + }, + { timeoutMs: 13_000 } + ) completeRequests.shift()?.() await second }) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 00abdd63093..e4c306529ee 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -40,6 +40,8 @@ type NonInteractiveExecQueueEntry = { release: () => void } +const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 + function isJsonRpcMethodNotFoundError(error: unknown): boolean { if (!error || typeof error !== 'object') { return false @@ -127,11 +129,16 @@ export class SshGitProvider implements IGitProvider { ? { bypassEffectiveUpstreamNegativeCache: true } : {} const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} + const branchLineTotalArgs = + options?.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: options.branchLineTotalMergeBase } const request = { worktreePath, ...includeIgnoredArgs, ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs + ...lineStatsReuseArgs, + ...branchLineTotalArgs } return (await (options?.signal ? this.mux.request('git.status', request, { signal: options.signal }) @@ -370,10 +377,9 @@ export class SshGitProvider implements IGitProvider { } } entry.started = true - return (await this.mux.request( - 'agent.execNonInteractive', - payload - )) as RemoteCommitMessageExecResult + return (await this.mux.request('agent.execNonInteractive', payload, { + timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS + })) as RemoteCommitMessageExecResult } finally { signal?.removeEventListener('abort', abortEntry) entry.release() diff --git a/src/main/providers/ssh-pty-notification-rejection-routing.test.ts b/src/main/providers/ssh-pty-notification-rejection-routing.test.ts new file mode 100644 index 00000000000..79314ae9ebf --- /dev/null +++ b/src/main/providers/ssh-pty-notification-rejection-routing.test.ts @@ -0,0 +1,183 @@ +import { expect, it, vi } from 'vitest' +import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' + +it('routes malformed and unadmitted source frames only to rejection diagnostics', async () => { + const mux = { + onNotification: vi.fn(), + request: vi.fn(async (_method: string, params: Record) => { + if (params.deliveryToken !== 'token-1') { + throw new Error('Unknown or stale PTY source delivery cancellation') + } + return { canceled: true, sentEndSu: 0, creditedEndSu: 0 } + }) + } + const dataListeners = new Set() + const rejectedDataListeners = new Set() + const subscription = subscribeSshPtyNotifications({ + mux: mux as never, + toAppPtyId: (id) => `ssh:conn@@${id}`, + dataListeners: dataListeners as never, + rejectedDataListeners: rejectedDataListeners as never, + replayListeners: new Set() as never, + exitListeners: new Set() as never, + livePtyIds: new Set(), + recordExit: vi.fn(), + providerGeneration: 7, + resolvePtyIncarnation: (id) => `incarnation:${id}`, + peekPtyIncarnation: () => undefined + }) + const handler = mux.onNotification.mock.calls[0]?.[0] as ( + method: string, + params: Record + ) => void + const onData = vi.fn() + const onRejectedData = vi.fn() + dataListeners.add(onData) + rejectedDataListeners.add(onRejectedData) + subscription + .installReceivingActivation('pty-1', { + status: 'pending', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + checkpointSourceEndSu: 0, + recoveryEndSu: 4 + }) + .commit() + + handler('pty.data', { + id: 'pty-1', + data: 'bad', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + sourceEndSu: 4, + sourceLengthSu: 4 + }) + handler('pty.data', { + id: 'pty-2', + data: 'old', + deliveryToken: 'token-old', + clientGeneration: 1, + ownerGeneration: 1, + ptyIncarnation: 'incarnation-1', + sourceEndSu: 3, + sourceLengthSu: 3 + }) + + await vi.waitFor(() => expect(onRejectedData).toHaveBeenCalledTimes(2)) + + expect(onData).not.toHaveBeenCalled() + expect(onRejectedData).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ sourceMalformed: true }) + ) + expect(onRejectedData).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + sourceRejected: true, + source: expect.objectContaining({ + deliveryToken: 'token-old', + clientGeneration: 1, + ownerGeneration: 1 + }) + }) + ) +}) + +it('never resolves an incarnation for a rejected frame', async () => { + const mux = { + onNotification: vi.fn(), + request: vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) + } + const rejectedDataListeners = new Set() + const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) + subscribeSshPtyNotifications({ + mux: mux as never, + toAppPtyId: (id) => `ssh:conn@@${id}`, + dataListeners: new Set() as never, + rejectedDataListeners: rejectedDataListeners as never, + replayListeners: new Set() as never, + exitListeners: new Set() as never, + livePtyIds: new Set(), + recordExit: vi.fn(), + providerGeneration: 7, + resolvePtyIncarnation, + peekPtyIncarnation: () => undefined + }) + const handler = mux.onNotification.mock.calls[0]?.[0] as ( + method: string, + params: Record + ) => void + const onRejectedData = vi.fn() + rejectedDataListeners.add(onRejectedData) + + // Why this matters: resolvePtyIncarnation caches what it mints and rememberPtyIncarnation is + // first-write-wins, so a synthetic id minted here would fence the PTY's real incarnation off for + // the rest of the generation. + handler('pty.data', { id: 'pty-1', data: 'bad', deliveryToken: 'token-1', sourceEndSu: 4 }) + + await vi.waitFor(() => expect(onRejectedData).toHaveBeenCalledOnce()) + + expect(onRejectedData).toHaveBeenCalledOnce() + expect(onRejectedData).toHaveBeenCalledWith(expect.objectContaining({ sourceMalformed: true })) + expect(resolvePtyIncarnation).not.toHaveBeenCalled() +}) + +it('coalesces repeated exact rejections into one fresh-activation recovery', async () => { + const mux = { + onNotification: vi.fn(), + request: vi.fn(async () => ({ canceled: true, sentEndSu: 3, creditedEndSu: 0 })) + } + const rejectedDataListeners = new Set() + const subscription = subscribeSshPtyNotifications({ + mux: mux as never, + toAppPtyId: (id) => `ssh:conn@@${id}`, + dataListeners: new Set() as never, + rejectedDataListeners: rejectedDataListeners as never, + replayListeners: new Set() as never, + exitListeners: new Set() as never, + livePtyIds: new Set(), + recordExit: vi.fn(), + providerGeneration: 7, + resolvePtyIncarnation: (id) => `incarnation:${id}`, + peekPtyIncarnation: () => 'incarnation-1' + }) + subscription + .installReceivingActivation('pty-1', { + status: 'pending', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }) + .commit() + const handler = mux.onNotification.mock.calls[0]?.[0] as ( + method: string, + params: Record + ) => void + const onRejectedData = vi.fn() + rejectedDataListeners.add(onRejectedData) + const rejected = { + id: 'pty-1', + data: 'gap', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + sourceEndSu: 6, + sourceLengthSu: 3 + } + + handler('pty.data', rejected) + handler('pty.data', rejected) + + await vi.waitFor(() => expect(onRejectedData).toHaveBeenCalledOnce()) + expect(onRejectedData).toHaveBeenCalledWith( + expect.objectContaining({ rejectedSourceRecovery: 'fresh-activation' }) + ) +}) diff --git a/src/main/providers/ssh-pty-notification-routing.test.ts b/src/main/providers/ssh-pty-notification-routing.test.ts index 5fb49553692..fdb6776bf36 100644 --- a/src/main/providers/ssh-pty-notification-routing.test.ts +++ b/src/main/providers/ssh-pty-notification-routing.test.ts @@ -29,7 +29,8 @@ function createSubscription() { livePtyIds, recordExit, providerGeneration: 7, - resolvePtyIncarnation + resolvePtyIncarnation, + peekPtyIncarnation: () => undefined }) const handler = mux.onNotification.mock.calls[0]?.[0] as ( diff --git a/src/main/providers/ssh-pty-notification-routing.ts b/src/main/providers/ssh-pty-notification-routing.ts index 8cd5c2cb86f..b6063799f58 100644 --- a/src/main/providers/ssh-pty-notification-routing.ts +++ b/src/main/providers/ssh-pty-notification-routing.ts @@ -7,10 +7,11 @@ import type { SshPtyReplayCallback } from './ssh-pty-provider-contract' import { parseSshPtySourceFrame } from './ssh-pty-source-frame' -import { - SshPtySourceDeliveryLedger, - type PendingSshPtySourceData -} from './ssh-pty-source-delivery-ledger' +import { SshPtySourceDeliveryLedger } from './ssh-pty-source-delivery-ledger' +import type { + PendingSshPtySourceData, + SshPtyRejectedSourceRecovery +} from './ssh-pty-source-delivery-state' export type { SshPtyDataCallback, SshPtyExitCallback, SshPtyReplayCallback } export type SshPtyRecoveryActivationLease = Readonly<{ @@ -35,18 +36,25 @@ export function subscribeSshPtyNotifications(args: { mux: SshChannelMultiplexer toAppPtyId: (id: string) => string dataListeners: Set + rejectedDataListeners?: Set replayListeners: Set exitListeners: Set livePtyIds: Set recordExit: (relayPtyId: string, incarnationId: unknown) => void providerGeneration: number resolvePtyIncarnation: (relayPtyId: string, incarnationId?: unknown) => string + peekPtyIncarnation: (relayPtyId: string) => string | undefined }): SshPtyNotificationSubscription { - const toDataPayload = (pending: PendingSshPtySourceData): Parameters[0] => { + const toDataPayload = ( + pending: PendingSshPtySourceData, + incarnationOverride?: string + ): Parameters[0] => { const id = args.toAppPtyId(pending.relayPtyId) - const ptyIncarnation = pending.source - ? (pending.params.ptyIncarnation as string) - : args.resolvePtyIncarnation(pending.relayPtyId, pending.params.incarnationId) + const ptyIncarnation = + incarnationOverride ?? + (pending.source + ? (pending.params.ptyIncarnation as string) + : args.resolvePtyIncarnation(pending.relayPtyId, pending.params.incarnationId)) return { id, data: pending.data, @@ -67,7 +75,80 @@ export function subscribeSshPtyNotifications(args: { listener(payload) } } + // Why: a rejected frame is diagnostic and must never mint an incarnation. resolvePtyIncarnation + // caches what it synthesizes and rememberPtyIncarnation is first-write-wins, so a malformed frame + // that lands before the PTY's first good one would pin a `legacy:` id the real attach can never + // displace — fencing every later frame of that generation off as a mismatch. + const rejectedPtyIncarnation = (pending: PendingSshPtySourceData): string => { + const offered = pending.params.ptyIncarnation + if (typeof offered === 'string' && offered.length > 0) { + return offered + } + return args.peekPtyIncarnation(pending.relayPtyId) ?? '' + } + const publishRejectedData = ( + pending: PendingSshPtySourceData, + rejection: 'malformed' | 'unadmitted', + recovery: SshPtyRejectedSourceRecovery + ): void => { + const listeners = args.rejectedDataListeners + if (!listeners || listeners.size === 0) { + return + } + const payload = { + ...toDataPayload(pending, rejectedPtyIncarnation(pending)), + ...(rejection === 'malformed' ? { sourceMalformed: true } : {}), + ...(rejection === 'unadmitted' ? { sourceRejected: true } : {}), + rejectedSourceRecovery: recovery + } + for (const listener of listeners) { + listener(payload) + } + } const sourceDeliveries = new SshPtySourceDeliveryLedger(args.mux, publishData) + const rejectedPublications = new Map< + string, + { + pending: number + payload: PendingSshPtySourceData + rejection: 'malformed' | 'unadmitted' + recovery?: SshPtyRejectedSourceRecovery + } + >() + const rejectSourceData = ( + pending: PendingSshPtySourceData, + rejection: 'malformed' | 'unadmitted' + ): void => { + const batch = rejectedPublications.get(pending.relayPtyId) ?? { + pending: 0, + payload: pending, + rejection + } + batch.pending++ + rejectedPublications.set(pending.relayPtyId, batch) + void sourceDeliveries + .reject(pending.relayPtyId, rejectedSourceIdentity(pending.params)) + .then((recovery) => { + if ( + !batch.recovery || + rejectedRecoveryPriority(recovery) > rejectedRecoveryPriority(batch.recovery) + ) { + batch.payload = pending + batch.rejection = rejection + batch.recovery = recovery + } + }) + .finally(() => { + batch.pending-- + queueMicrotask(() => { + if (batch.pending > 0 || rejectedPublications.get(pending.relayPtyId) !== batch) { + return + } + rejectedPublications.delete(pending.relayPtyId) + publishRejectedData(batch.payload, batch.rejection, batch.recovery ?? 'reconnect-channel') + }) + }) + } const dispose = args.mux.onNotification((method, params) => { // Why: mux delivers every method to generic handlers; non-PTY payloads // (workspace.changed, fs.changed, …) have no `id` and must not reach @@ -82,6 +163,7 @@ export function subscribeSshPtyNotifications(args: { if (method === 'pty.exit') { const id = args.toAppPtyId(relayPtyId) const ptyIncarnation = args.resolvePtyIncarnation(relayPtyId, params.incarnationId) + rejectedPublications.delete(relayPtyId) args.recordExit(relayPtyId, params.incarnationId) args.livePtyIds.delete(id) sourceDeliveries.recordExit(relayPtyId) @@ -109,7 +191,8 @@ export function subscribeSshPtyNotifications(args: { const data = typeof params.data === 'string' ? params.data : '' const sourceFrame = parseSshPtySourceFrame(params, data, relayPtyId) if (sourceFrame.malformed) { - cancelExactSourceDelivery(args.mux, relayPtyId, params) + const pending = Object.freeze({ relayPtyId, params, data }) + rejectSourceData(pending, 'malformed') return } const pending = Object.freeze({ @@ -120,14 +203,17 @@ export function subscribeSshPtyNotifications(args: { }) if (sourceFrame.source) { if (!sourceDeliveries.admit({ ...pending, source: sourceFrame.source })) { - cancelExactSourceDelivery(args.mux, relayPtyId, params) + rejectSourceData(pending, 'unadmitted') } return } publishData(pending) }) return Object.freeze({ - dispose, + dispose: () => { + rejectedPublications.clear() + dispose() + }, installReceivingActivation: (relayPtyId, activation) => { const lease = sourceDeliveries.install(relayPtyId, activation) return Object.freeze({ @@ -140,33 +226,42 @@ export function subscribeSshPtyNotifications(args: { }) } -function cancelExactSourceDelivery( - mux: SshChannelMultiplexer, - relayPtyId: string, - params: { - deliveryToken?: unknown - clientGeneration?: unknown - ownerGeneration?: unknown +function rejectedRecoveryPriority(recovery: SshPtyRejectedSourceRecovery): number { + if (recovery === 'reconnect-channel') { + return 3 } -): void { + return recovery === 'fresh-activation' ? 2 : 1 +} + +function rejectedSourceIdentity(params: { + deliveryToken?: unknown + clientGeneration?: unknown + ownerGeneration?: unknown + ptyIncarnation?: unknown +}): + | Readonly<{ + deliveryToken: string + clientGeneration: number + ownerGeneration: number + ptyIncarnation: string + }> + | undefined { if ( typeof params.deliveryToken !== 'string' || params.deliveryToken.length === 0 || !positiveSafeInteger(params.clientGeneration) || - !positiveSafeInteger(params.ownerGeneration) + !positiveSafeInteger(params.ownerGeneration) || + typeof params.ptyIncarnation !== 'string' || + params.ptyIncarnation.length === 0 ) { - return + return undefined } - try { - void mux - .request('pty.cancelDelivery', { - id: relayPtyId, - clientGeneration: params.clientGeneration, - ownerGeneration: params.ownerGeneration, - deliveryToken: params.deliveryToken - }) - .catch(() => {}) - } catch {} + return Object.freeze({ + deliveryToken: params.deliveryToken, + clientGeneration: params.clientGeneration, + ownerGeneration: params.ownerGeneration, + ptyIncarnation: params.ptyIncarnation + }) } function positiveSafeInteger(value: unknown): value is number { diff --git a/src/main/providers/ssh-pty-provider-contract.ts b/src/main/providers/ssh-pty-provider-contract.ts index 18c21bdb754..4ac6f0fb4b2 100644 --- a/src/main/providers/ssh-pty-provider-contract.ts +++ b/src/main/providers/ssh-pty-provider-contract.ts @@ -27,6 +27,8 @@ export type SshPtyDataCallback = (payload: { sourceEndSu: number }> sourceMalformed?: boolean + sourceRejected?: boolean + rejectedSourceRecovery?: 'confirm-existing' | 'fresh-activation' | 'reconnect-channel' }) => void export type SshPtyReplayCallback = (payload: { id: string; data: string }) => void export type SshPtyExitCallback = (payload: { diff --git a/src/main/providers/ssh-pty-provider-output-state.ts b/src/main/providers/ssh-pty-provider-output-state.ts index fd9bde5255f..4d98097b9c6 100644 --- a/src/main/providers/ssh-pty-provider-output-state.ts +++ b/src/main/providers/ssh-pty-provider-output-state.ts @@ -14,6 +14,7 @@ import type { PtySourceReceivingActivation } from '../../shared/pty-source-recei export class SshPtyProviderOutputState { private readonly dataListeners = new Set() + private readonly rejectedDataListeners = new Set() private readonly replayListeners = new Set() private readonly exitListeners = new Set() private readonly incarnationByRelayPtyId = new Map() @@ -34,11 +35,13 @@ export class SshPtyProviderOutputState { this.subscription = subscribeSshPtyNotifications({ ...args, dataListeners: this.dataListeners, + rejectedDataListeners: this.rejectedDataListeners, replayListeners: this.replayListeners, exitListeners: this.exitListeners, providerGeneration, resolvePtyIncarnation: (relayPtyId, incarnationId) => this.resolvePtyIncarnation(relayPtyId, incarnationId), + peekPtyIncarnation: (relayPtyId) => this.incarnationByRelayPtyId.get(relayPtyId), recordExit: (relayPtyId, incarnationId) => { args.recordExit(relayPtyId, incarnationId) this.incarnationByRelayPtyId.delete(relayPtyId) @@ -52,6 +55,7 @@ export class SshPtyProviderOutputState { this.subscription?.dispose() this.subscription = null this.dataListeners.clear() + this.rejectedDataListeners.clear() this.replayListeners.clear() this.exitListeners.clear() this.incarnationByRelayPtyId.clear() @@ -63,6 +67,11 @@ export class SshPtyProviderOutputState { return () => this.dataListeners.delete(callback) } + onRejectedData(callback: SshPtyDataCallback): () => void { + this.rejectedDataListeners.add(callback) + return () => this.rejectedDataListeners.delete(callback) + } + onReplay(callback: SshPtyReplayCallback): () => void { this.replayListeners.add(callback) return () => this.replayListeners.delete(callback) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 5811a19d52d..2bc52fe491d 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -314,6 +314,8 @@ export class SshPtyProvider implements IPtyProvider { } onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback) + onRejectedData = (callback: SshPtyDataCallback): (() => void) => + this.outputState.onRejectedData(callback) onReplay = (callback: SshPtyReplayCallback): (() => void) => this.outputState.onReplay(callback) onExit = (callback: SshPtyExitCallback): (() => void) => this.outputState.onExit(callback) diff --git a/src/main/providers/ssh-pty-source-delivery-ledger.test.ts b/src/main/providers/ssh-pty-source-delivery-ledger.test.ts index 4040b56a0d3..7b1f7aa2b13 100644 --- a/src/main/providers/ssh-pty-source-delivery-ledger.test.ts +++ b/src/main/providers/ssh-pty-source-delivery-ledger.test.ts @@ -2,6 +2,17 @@ import { describe, expect, it, vi } from 'vitest' import { SshPtySourceDeliveryLedger } from './ssh-pty-source-delivery-ledger' describe('SshPtySourceDeliveryLedger', () => { + const activation = (deliveryToken: string) => + Object.freeze({ + status: 'pending' as const, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken, + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }) + it('retains cancellation ownership when recovery transfer is superseded', async () => { const request = vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) const ledger = new SshPtySourceDeliveryLedger({ request } as never, vi.fn()) @@ -41,4 +52,44 @@ describe('SshPtySourceDeliveryLedger', () => { deliveryToken: 'token-old' }) }) + + it('retires an exact rejected activation only after cancellation is proven', async () => { + const request = vi.fn(async () => ({ canceled: true, sentEndSu: 4, creditedEndSu: 0 })) + const ledger = new SshPtySourceDeliveryLedger({ request } as never, vi.fn()) + ledger.install('pty-1', activation('token-old')).commit() + + await expect(ledger.reject('pty-1', activation('token-old'))).resolves.toBe('fresh-activation') + + expect(() => ledger.install('pty-1', activation('token-new')).commit()).not.toThrow() + }) + + it('keeps the current activation when a stale rejected token cannot cancel it', async () => { + const publish = vi.fn() + const request = vi.fn(async () => { + throw new Error('Unknown or stale PTY source delivery cancellation') + }) + const ledger = new SshPtySourceDeliveryLedger({ request } as never, publish) + ledger.install('pty-1', activation('token-current')).commit() + + await expect(ledger.reject('pty-1', activation('token-stale'))).resolves.toBe( + 'confirm-existing' + ) + expect( + ledger.admit({ + relayPtyId: 'pty-1', + params: { ptyIncarnation: 'incarnation-1' }, + data: 'healthy', + source: { + relayPtyId: 'pty-1', + spanId: 'span-1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-current', + sourceStartSu: 0, + sourceEndSu: 7 + } + }) + ).toBe(true) + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ data: 'healthy' })) + }) }) diff --git a/src/main/providers/ssh-pty-source-delivery-ledger.ts b/src/main/providers/ssh-pty-source-delivery-ledger.ts index 9e101345c49..717e8554f44 100644 --- a/src/main/providers/ssh-pty-source-delivery-ledger.ts +++ b/src/main/providers/ssh-pty-source-delivery-ledger.ts @@ -1,40 +1,21 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { SshPtySourceFrame } from './ssh-pty-source-frame' - -export type PendingSshPtySourceData = Readonly<{ - relayPtyId: string - params: Record - data: string - source?: SshPtySourceFrame -}> - -type SourceDeliveryLeaseState = { - phase: 'provisional' | 'recovery' | 'committing' | 'committed' | 'retired' - pendingData: PendingSshPtySourceData[] - recoverySink?: (pending: PendingSshPtySourceData) => void - exited: boolean -} - -type SourceDeliveryState = Readonly<{ - activation: PtySourceReceivingActivation - sourceEndSu: number - lease: SourceDeliveryLeaseState - previous?: SourceDeliveryState -}> - -export type SshPtyRecoveryActivationLease = Readonly<{ - commit: () => void - retire: () => void -}> - -export type SshPtySourceDeliveryLease = Readonly<{ - commit: () => void - rollback: () => Promise - transferToRecovery: ( - sink: (pending: PendingSshPtySourceData) => void - ) => SshPtyRecoveryActivationLease -}> +import { + acceptsSourceFrame, + activePredecessor, + sameReceivingActivation, + sameRejectedSourceIdentity, + settleExactSourceDeliveryCancellation, + settledReceivingActivationLease, + type PendingSshPtySourceData, + type RejectedSourceIdentity, + type SourceDeliveryLeaseState, + type SourceDeliveryState, + type SshPtyRecoveryActivationLease, + type SshPtyRejectedSourceRecovery, + type SshPtySourceDeliveryLease +} from './ssh-pty-source-delivery-state' export class SshPtySourceDeliveryLedger { private readonly deliveryByPty = new Map() @@ -95,6 +76,32 @@ export class SshPtySourceDeliveryLedger { } } + async reject( + relayPtyId: string, + identity: RejectedSourceIdentity | undefined + ): Promise { + if (!identity) { + return 'reconnect-channel' + } + const offeredCurrent = this.deliveryByPty.get(relayPtyId) + const matched = Boolean( + offeredCurrent && sameRejectedSourceIdentity(offeredCurrent.activation, identity) + ) + const canceled = await settleExactSourceDeliveryCancellation(this.mux, relayPtyId, identity) + if (matched && canceled) { + const current = this.deliveryByPty.get(relayPtyId) + if (current && sameRejectedSourceIdentity(current.activation, identity)) { + this.retire(relayPtyId, current.previous, current.lease) + return 'fresh-activation' + } + return current ? 'reconnect-channel' : 'fresh-activation' + } + if (!matched && !canceled) { + return 'confirm-existing' + } + return 'reconnect-channel' + } + private installProvisional( relayPtyId: string, activation: PtySourceReceivingActivation, @@ -245,76 +252,3 @@ export class SshPtySourceDeliveryLedger { } } } - -function settledReceivingActivationLease(): SshPtySourceDeliveryLease { - return Object.freeze({ - commit: () => {}, - rollback: async () => true, - transferToRecovery: () => Object.freeze({ commit: () => {}, retire: () => {} }) - }) -} - -function activePredecessor(previous?: SourceDeliveryState): SourceDeliveryState | undefined { - while (previous?.lease.phase === 'retired') { - previous = previous.previous - } - return previous -} - -function sameReceivingActivation( - left: PtySourceReceivingActivation, - right: PtySourceReceivingActivation -): boolean { - return ( - left.clientGeneration === right.clientGeneration && - left.ownerGeneration === right.ownerGeneration && - left.ptyIncarnation === right.ptyIncarnation && - left.deliveryToken === right.deliveryToken && - left.checkpointSourceEndSu === right.checkpointSourceEndSu && - left.recoveryEndSu === right.recoveryEndSu - ) -} - -function acceptsSourceFrame( - current: SourceDeliveryState | undefined, - params: Record, - source: SshPtySourceFrame -): current is SourceDeliveryState { - return Boolean( - current && - current.lease.phase !== 'retired' && - !current.lease.exited && - current.activation.ptyIncarnation === params.ptyIncarnation && - current.activation.deliveryToken === source.deliveryToken && - current.activation.clientGeneration === source.clientGeneration && - current.activation.ownerGeneration === source.ownerGeneration && - current.sourceEndSu === source.sourceStartSu - ) -} - -async function settleExactSourceDeliveryCancellation( - mux: SshChannelMultiplexer, - relayPtyId: string, - activation: PtySourceReceivingActivation -): Promise { - try { - const result = (await mux.request('pty.cancelDelivery', { - id: relayPtyId, - clientGeneration: activation.clientGeneration, - ownerGeneration: activation.ownerGeneration, - deliveryToken: activation.deliveryToken - })) as Record - return ( - result.canceled === true && - nonNegativeSafeInteger(result.sentEndSu) && - nonNegativeSafeInteger(result.creditedEndSu) && - result.creditedEndSu <= result.sentEndSu - ) - } catch { - return false - } -} - -function nonNegativeSafeInteger(value: unknown): value is number { - return Number.isSafeInteger(value) && (value as number) >= 0 -} diff --git a/src/main/providers/ssh-pty-source-delivery-state.ts b/src/main/providers/ssh-pty-source-delivery-state.ts new file mode 100644 index 00000000000..efdcbfb5807 --- /dev/null +++ b/src/main/providers/ssh-pty-source-delivery-state.ts @@ -0,0 +1,134 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' +import type { SshPtySourceFrame } from './ssh-pty-source-frame' + +export type PendingSshPtySourceData = Readonly<{ + relayPtyId: string + params: Record + data: string + source?: SshPtySourceFrame +}> + +export type SourceDeliveryLeaseState = { + phase: 'provisional' | 'recovery' | 'committing' | 'committed' | 'retired' + pendingData: PendingSshPtySourceData[] + recoverySink?: (pending: PendingSshPtySourceData) => void + exited: boolean +} + +export type SourceDeliveryState = Readonly<{ + activation: PtySourceReceivingActivation + sourceEndSu: number + lease: SourceDeliveryLeaseState + previous?: SourceDeliveryState +}> + +export type SshPtyRecoveryActivationLease = Readonly<{ + commit: () => void + retire: () => void +}> + +export type SshPtySourceDeliveryLease = Readonly<{ + commit: () => void + rollback: () => Promise + transferToRecovery: ( + sink: (pending: PendingSshPtySourceData) => void + ) => SshPtyRecoveryActivationLease +}> + +export type SshPtyRejectedSourceRecovery = + | 'confirm-existing' + | 'fresh-activation' + | 'reconnect-channel' + +export type RejectedSourceIdentity = Readonly<{ + clientGeneration: number + ownerGeneration: number + ptyIncarnation: string + deliveryToken: string +}> + +export function settledReceivingActivationLease(): SshPtySourceDeliveryLease { + return Object.freeze({ + commit: () => {}, + rollback: async () => true, + transferToRecovery: () => Object.freeze({ commit: () => {}, retire: () => {} }) + }) +} + +export function activePredecessor(previous?: SourceDeliveryState): SourceDeliveryState | undefined { + while (previous?.lease.phase === 'retired') { + previous = previous.previous + } + return previous +} + +export function sameReceivingActivation( + left: PtySourceReceivingActivation, + right: PtySourceReceivingActivation +): boolean { + return ( + left.clientGeneration === right.clientGeneration && + left.ownerGeneration === right.ownerGeneration && + left.ptyIncarnation === right.ptyIncarnation && + left.deliveryToken === right.deliveryToken && + left.checkpointSourceEndSu === right.checkpointSourceEndSu && + left.recoveryEndSu === right.recoveryEndSu + ) +} + +export function sameRejectedSourceIdentity( + left: PtySourceReceivingActivation, + right: RejectedSourceIdentity +): boolean { + return ( + left.clientGeneration === right.clientGeneration && + left.ownerGeneration === right.ownerGeneration && + left.ptyIncarnation === right.ptyIncarnation && + left.deliveryToken === right.deliveryToken + ) +} + +export function acceptsSourceFrame( + current: SourceDeliveryState | undefined, + params: Record, + source: SshPtySourceFrame +): current is SourceDeliveryState { + return Boolean( + current && + current.lease.phase !== 'retired' && + !current.lease.exited && + current.activation.ptyIncarnation === params.ptyIncarnation && + current.activation.deliveryToken === source.deliveryToken && + current.activation.clientGeneration === source.clientGeneration && + current.activation.ownerGeneration === source.ownerGeneration && + current.sourceEndSu === source.sourceStartSu + ) +} + +export async function settleExactSourceDeliveryCancellation( + mux: SshChannelMultiplexer, + relayPtyId: string, + activation: RejectedSourceIdentity +): Promise { + try { + const result = (await mux.request('pty.cancelDelivery', { + id: relayPtyId, + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken + })) as Record + return ( + result.canceled === true && + nonNegativeSafeInteger(result.sentEndSu) && + nonNegativeSafeInteger(result.creditedEndSu) && + result.creditedEndSu <= result.sentEndSu + ) + } catch { + return false + } +} + +function nonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 +} diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index e4b1c9d06a6..15c6b43cf44 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from 'vitest' +import { + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, + SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV +} from '../../shared/setup-agent-sequencing' import { addOrcaWslInteropEnv, addWorktreeSetupWslInteropEnv, @@ -14,6 +18,20 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u') }) + it('imports setup-gated startup env into WSL without path translation', () => { + const env: Record = { + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'codex', + [SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: 'while :; do sleep 1; done' + } + + addOrcaWslInteropEnv(env) + + expect(env.WSLENV?.split(':')).toEqual([ + `${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}/u`, + `${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}/u` + ]) + }) + it('preserves existing WSLENV entries and does not duplicate the handle entry', () => { const env: Record = { WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p' diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index bc4ec7aee8f..40f3b957249 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -4,6 +4,10 @@ import { ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV, ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV } from '../../shared/orchestration-compatibility-evidence' +import { + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, + SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV +} from '../../shared/setup-agent-sequencing' const WSLENV_ENTRY_SEPARATOR = ':' @@ -77,6 +81,8 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', 'ORCA_AGENT_LAUNCH_TOKEN/u', + `${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}/u`, + `${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}/u`, 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u', 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u', 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u', diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index 7c55f75327b..5cc74aa4ad4 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits' import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' const { @@ -42,7 +43,8 @@ const { ]), getDefaultProfile: vi.fn(), getProfile: vi.fn(), - resolveKnownPartition: vi.fn() + resolveKnownPartition: vi.fn(), + createProfile: vi.fn() } })) @@ -129,6 +131,32 @@ describe('RuntimeBrowserCommands browser screencast', () => { return browserSessionRegistryMock.profiles.get(profileId)?.partition ?? null } ) + browserSessionRegistryMock.createProfile.mockReset() + }) + + it('creates profiles with the requested user-agent mode', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const profile = { + id: 'profile-google', + scope: 'isolated', + partition: 'persist:orca-browser-session-profile-google', + label: 'Google', + source: null, + userAgentMode: 'native' + } + browserSessionRegistryMock.createProfile.mockReturnValue(profile) + const commands = new RuntimeBrowserCommands(createHost()) + + await expect( + commands.browserProfileCreate({ + label: 'Google', + scope: 'isolated', + userAgentMode: 'native' + }) + ).resolves.toEqual({ profile }) + expect(browserSessionRegistryMock.createProfile).toHaveBeenCalledWith('isolated', 'Google', { + userAgentMode: 'native' + }) }) it('waits for explicit worktree browser registration after requesting a hidden mount', async () => { @@ -552,6 +580,32 @@ describe('RuntimeBrowserCommands browser screencast', () => { await second.session.done expect(secondStop).toHaveBeenCalledTimes(1) }, 10_000) + + it('admits screencast frames through the paired-runtime size guard', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) + const done = deferred() + startBrowserScreencastMock.mockResolvedValue({ + stop: vi.fn(() => done.resolve()), + done: done.promise + }) + const sendBinary = vi.fn(() => true) + + const commands = new RuntimeBrowserCommands(createHost()) + const started = await commands.browserScreencast( + { worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' }, + { sendBinary } + ) + const { onFrame } = startBrowserScreencastMock.mock.calls[0][1] + + expect(onFrame(new Uint8Array(REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES + 1))).toBe(true) + expect(sendBinary).not.toHaveBeenCalled() + expect(onFrame(new Uint8Array(64))).toBe(true) + expect(sendBinary).toHaveBeenCalledTimes(1) + + started.session.stop() + await started.session.done + }) }) describe('RuntimeBrowserCommands headless offscreen routing', () => { diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index a2d72969226..6070cefd374 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -49,7 +49,10 @@ import type { BrowserViewportResult, BrowserWaitResult } from '../../shared/runtime-types' -import type { BrowserCertificateProceedResult } from '../../shared/types' +import type { + BrowserCertificateProceedResult, + BrowserSessionUserAgentMode +} from '../../shared/types' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' @@ -65,6 +68,7 @@ import { selectBrowserProfile } from '../browser/browser-cookie-import' import { waitForTabRegistration, waitForWorktreeTabRegistration } from '../ipc/browser' +import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' export type BrowserCommandTargetParams = { worktree?: string @@ -504,7 +508,7 @@ export class RuntimeBrowserCommands { mobile: params.mobile === true, everyNthFrame: clampInteger(params.everyNthFrame, 1, 10, 2), minFrameIntervalMs: clampInteger(params.minFrameIntervalMs, 0, 1000, 0), - onFrame: stream.sendBinary, + onFrame: (bytes) => sendRemoteBrowserScreencastFrame(stream.sendBinary, bytes), onEvent: stream.emit, onError: (message) => stream.emit?.({ type: 'error', message }) }) @@ -1496,9 +1500,12 @@ export class RuntimeBrowserCommands { async browserProfileCreate(params: { label: string scope: 'isolated' | 'imported' + userAgentMode?: BrowserSessionUserAgentMode }): Promise { return { - profile: browserSessionRegistry.createProfile(params.scope, params.label) + profile: browserSessionRegistry.createProfile(params.scope, params.label, { + userAgentMode: params.userAgentMode + }) } } diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e1da3403d80..e5c8a18479e 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -836,9 +836,17 @@ describe('RuntimeFileCommands', () => { commands: RuntimeFileCommands, pathText: string, cwd: string | null = null, - clientId = 'client-a' + clientId = 'client-a', + crossWorkspace = false ) { - return commands.resolveTerminalPath('id:wt-1', pathText, cwd, clientId, 'term-1') + return commands.resolveTerminalPath( + 'id:wt-1', + pathText, + cwd, + clientId, + 'term-1', + crossWorkspace + ) } function createRemoteTerminalArtifactGrantFixture(artifactPath = '/tmp/result.json') { @@ -911,7 +919,14 @@ describe('RuntimeFileCommands', () => { }) statAsFile() - const result = await commands.resolveTerminalPath('id:wt-1', '/sibling/docs/readme.md') + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '/sibling/docs/readme.md', + null, + undefined, + null, + true + ) expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( '/sibling/docs/readme.md', @@ -958,7 +973,7 @@ describe('RuntimeFileCommands', () => { resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) statMock.mockResolvedValue({ isDirectory: () => true }) - const result = await resolveTerminalArtifactPath(commands, '/sibling') + const result = await resolveTerminalArtifactPath(commands, '/sibling', null, 'client-a', true) expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith('/sibling', 'local') expect(result).toEqual({ @@ -999,7 +1014,14 @@ describe('RuntimeFileCommands', () => { const remoteStat = vi.fn().mockResolvedValue({ type: 'file', size: 12, mtime: 3 }) vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never) - const result = await commands.resolveTerminalPath('id:wt-1', '/sibling/docs/readme.md') + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '/sibling/docs/readme.md', + null, + undefined, + null, + true + ) expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( '/sibling/docs/readme.md', @@ -1044,7 +1066,7 @@ describe('RuntimeFileCommands', () => { const remoteStat = vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 3 }) vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat: remoteStat } as never) - const result = await resolveTerminalArtifactPath(commands, '/sibling') + const result = await resolveTerminalArtifactPath(commands, '/sibling', null, 'client-a', true) expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith('/sibling', 'ssh:ssh-1') expect(remoteStat).toHaveBeenCalledWith('/sibling') @@ -1068,7 +1090,14 @@ describe('RuntimeFileCommands', () => { resolveKnownWorkspaceFileTarget }) - await commands.resolveTerminalPath('id:wt-1', '/repo-b/docs/readme.md') + await commands.resolveTerminalPath( + 'id:wt-1', + '/repo-b/docs/readme.md', + null, + undefined, + null, + true + ) expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith( '/repo-b/docs/readme.md', @@ -1076,6 +1105,36 @@ describe('RuntimeFileCommands', () => { ) }) + // Why: clients predating crossWorkspace (mobile <=0.0.36) reuse their own worktree + // id for files.open, so they must keep the pre-sibling-resolution contract. + it('keeps the caller worktree and a null relativePath when crossWorkspace is not requested', async () => { + const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({ + worktree: { + id: 'wt-2', + repoId: 'repo-2', + path: '/sibling', + git: { path: '/sibling', head: '', branch: '', isBare: false, isMainWorktree: true } + }, + relativePath: 'docs/readme.md' + })) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + resolveKnownWorkspaceFileTarget + }) + statAsFile() + + const result = await commands.resolveTerminalPath('id:wt-1', '/sibling/docs/readme.md') + + expect(resolveKnownWorkspaceFileTarget).not.toHaveBeenCalled() + expect(result).toEqual({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/sibling/docs/readme.md', + exists: false, + isDirectory: false + }) + }) + it('resolves a relative path against the provided cwd', async () => { const { commands } = createRuntimeFileCommands({ path: '/repo' }) statAsFile() diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 08f18da2de0..68000f1b297 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -38,6 +38,7 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import { sortDirEntries } from '../../shared/file-name-sort' import type { RuntimeFileListResult, RuntimeFileOpenResult, @@ -719,7 +720,8 @@ export class RuntimeFileCommands { pathText: string, cwd?: string | null, clientId?: string, - terminalHandle?: string | null + terminalHandle?: string | null, + crossWorkspace?: boolean ): Promise { const store = this.host.requireStore() const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) @@ -757,8 +759,10 @@ export class RuntimeFileCommands { terminalFileUriHostname }) const relativePath = relativePathInsideRoot(worktree.path, absolutePath) + // Why: clients that predate crossWorkspace reuse their own worktree id for the + // follow-up files.open, so retargeting to a sibling workspace must be opt-in. const knownWorkspaceTarget = - relativePath === null + crossWorkspace && relativePath === null ? await this.host.resolveKnownWorkspaceFileTarget?.( absolutePath, getRuntimeFileTargetExecutionHostId(target) @@ -1289,7 +1293,9 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.readDir(target.path) + // Why: re-sort locally — the remote relay may be an older build with + // lexicographic ordering. + return sortDirEntries(await provider.readDir(target.path)) } const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) @@ -1304,12 +1310,7 @@ export class RuntimeFileCommands { } }) ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return sortDirEntries(mapped) } async watchFileExplorer( diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index 544b9b6b217..fcde47d5a16 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ generatePullRequestFieldsFromContext: vi.fn(), resolveCommitMessageSettings: vi.fn(), resolveHostedReviewBodyForGeneration: vi.fn(), + loadPullRequestLinkedIssue: vi.fn(), getSshGitProvider: vi.fn(), getStatus: vi.fn() })) @@ -60,6 +61,10 @@ vi.mock('../source-control/pull-request-template', () => ({ resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration })) +vi.mock('../source-control/pull-request-linked-issue', () => ({ + loadPullRequestLinkedIssue: mocks.loadPullRequestLinkedIssue +})) + const tempDirs: string[] = [] function makeWorktree(path: string, linkedIssue: number | null = null): ResolvedRuntimeGitWorktree { @@ -101,6 +106,8 @@ describe('RuntimeGitCommands', () => { mocks.resolveCommitMessageSettings.mockReset() mocks.resolveHostedReviewBodyForGeneration.mockReset() mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body) + mocks.loadPullRequestLinkedIssue.mockReset() + mocks.loadPullRequestLinkedIssue.mockResolvedValue(null) mocks.getSshGitProvider.mockReset() mocks.getStatus.mockReset() mocks.checkoutBranch.mockReset() diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index fe1059962ec..88fb102f7e9 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -82,6 +82,10 @@ import { normalizeRuntimeRelativePath } from './runtime-relative-paths' import { gitExecFileAsync } from '../git/runner' import type { GitRuntimeOptions } from '../git/git-runtime-options' import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' +import { + loadPullRequestLinkedIssue, + type PullRequestLinkedIssueMeta +} from '../source-control/pull-request-linked-issue' import type { HostedReviewProvider } from '../../shared/hosted-review' export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo } @@ -171,6 +175,7 @@ export type RuntimeGitCommandHost = { * unlinked. */ getWorktreeLinkedIssue?(worktreeId: string): number | null | undefined + getWorktreeLinkedIssueMeta?(worktreeId: string): PullRequestLinkedIssueMeta | null | undefined } export class RuntimeGitCommands { @@ -182,6 +187,19 @@ export class RuntimeGitCommands { return live === undefined ? target.worktree.linkedIssue : live } + private linkedIssueMetaForTarget(target: RuntimeGitTarget): PullRequestLinkedIssueMeta | null { + const live = this.host.getWorktreeLinkedIssueMeta?.(target.worktree.id) + if (live !== undefined) { + return live + } + const liveGitHubIssue = this.host.getWorktreeLinkedIssue?.(target.worktree.id) + return { + linkedIssue: liveGitHubIssue === undefined ? target.worktree.linkedIssue : liveGitHubIssue, + linkedGitLabIssue: target.worktree.linkedGitLabIssue, + linkedWorkItem: target.worktree.linkedWorkItem + } + } + async getRuntimeGitStatus( worktreeSelector: string, options?: GitProviderStatusOptions @@ -722,6 +740,14 @@ export class RuntimeGitCommands { error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } } + const issueMeta = this.linkedIssueMetaForTarget(target) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: input.provider, + repoPath: target.worktree.path, + connectionId: target.connectionId, + localGitOptions: localGitOptionsForTarget(target) + }) let context: Awaited> try { const currentBody = await resolveHostedReviewBodyForGeneration({ @@ -761,8 +787,12 @@ export class RuntimeGitCommands { if (!context) { return { success: false, error: 'No branch changes to summarize.' } } - // Why: both SSH and local branches share this context, so one attach covers each. - context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target)) + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(input.provider ? { provider: input.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } if (target.connectionId) { return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { diff --git a/src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts b/src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts new file mode 100644 index 00000000000..8a4fae0dfac --- /dev/null +++ b/src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts @@ -0,0 +1,211 @@ +// issue1-ui-flash (host half): `session.tabs` must not manufacture completion or +// freshness. A neutral live title ('Terminal', an editor, a cwd) is not proof the +// agent finished, and the identity-only fallback must date its evidence instead of +// the byte stream — otherwise the host frame is perpetually newer than a paired +// client's own live status and the pane flaps between the two writers. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { makePaneKey } from '../../shared/stable-pane-id' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TAB_ID = 'remote-tab' +const WORKTREE_ID = 'wt-1' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const PTY_ID = 'pty-remote' +const T0 = 1_700_000_000_000 +const PROVIDER_SESSION = { + key: 'session_id' as const, + id: 'ac1f6b90-2f77-4f0e-9c5e-1d2f6a4b8c31', + transcriptPath: '/transcripts/ac1f6b90.jsonl' +} + +async function createRuntime(rows: AgentStatusIpcPayload[] = []): Promise { + const runtime = new OrcaRuntimeService(null, undefined, { + getAgentStatusSnapshot: () => rows + }) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: WORKTREE_ID, + path: '/repo/app', + connectionId: null, + repo: null, + folderWorkspace: null + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: PTY_ID }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + leafId: LEAF_ID, + launchAgent: 'claude', + title: 'Terminal' + }) + return runtime +} + +/** Publish a renderer graph: one terminal pane with a live title and the + * renderer's own byte-derived agent status (what a paired host mirrors). */ +function publishRendererWorkingPane(runtime: OrcaRuntimeService, paneTitle: string): void { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle + } + ], + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `${TAB_ID}::${LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${TAB_ID}::${LEAF_ID}`, + parentTabId: TAB_ID, + leafId: LEAF_ID, + title: paneTitle, + launchAgent: 'claude', + agentStatus: { + state: 'working', + prompt: 'Configurar y entender los comprobantes', + updatedAt: T0, + stateStartedAt: T0, + agentType: 'claude', + paneKey: PANE_KEY, + stateHistory: [] + }, + isActive: true + } + ] + } + ] + }) +} + +async function projectAgentStatus( + runtime: OrcaRuntimeService +): Promise | undefined> { + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + return tab?.type === 'terminal' + ? (tab.agentStatus as unknown as Record | undefined) + : undefined +} + +describe('mobile session tabs: live-title evidence vs published agent status', () => { + it('keeps renderer-published working under a neutral live title', async () => { + const runtime = await createRuntime() + publishRendererWorkingPane(runtime, 'Terminal') + + expect(await projectAgentStatus(runtime)).toEqual( + expect.objectContaining({ + state: 'working', + prompt: 'Configurar y entender los comprobantes' + }) + ) + }) + + // #1437 preserved: a genuine shell prompt is proof the agent released the pane. + it.each(['zsh', 'bash', 'pwsh'])( + 'clears a stale working to identity-only done under the shell title %s', + async (shell) => { + const runtime = await createRuntime() + publishRendererWorkingPane(runtime, shell) + + const agentStatus = await projectAgentStatus(runtime) + expect(agentStatus).toEqual(expect.objectContaining({ state: 'done', prompt: '' })) + expect(agentStatus).toHaveProperty('agentType', 'claude') + } + ) + + it('clears a stale working under a Claude management title', async () => { + const runtime = await createRuntime() + publishRendererWorkingPane(runtime, 'claude agents') + + expect(await projectAgentStatus(runtime)).toEqual( + expect.objectContaining({ state: 'done', prompt: '' }) + ) + }) +}) + +describe('mobile session tabs: identity-only fallback freshness', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(T0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('dates the identity-only done by its title evidence, not by output bytes', async () => { + const resumeRow: AgentStatusIpcPayload = { + paneKey: PANE_KEY, + state: 'done', + prompt: '', + agentType: 'claude', + connectionId: null, + receivedAt: T0, + stateStartedAt: T0, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + providerSession: PROVIDER_SESSION, + providerSessionOnly: true + } + const runtime = await createRuntime([resumeRow]) + // Title evidence lands once... + runtime.onPtyData(PTY_ID, '\x1b]0;Terminal\x07', T0) + const titleObservedAt = Date.now() + + // ...then the pane keeps streaming output with no new status evidence. + vi.setSystemTime(T0 + 60_000) + runtime.onPtyData(PTY_ID, 'building...\r\n', T0 + 60_000) + + const agentStatus = await projectAgentStatus(runtime) + expect(agentStatus).toEqual( + expect.objectContaining({ + state: 'done', + updatedAt: titleObservedAt, + stateStartedAt: titleObservedAt + }) + ) + + // Republishing after still more output must not refresh the frame. + vi.setSystemTime(T0 + 120_000) + runtime.onPtyData(PTY_ID, 'still building...\r\n', T0 + 120_000) + expect(await projectAgentStatus(runtime)).toEqual( + expect.objectContaining({ updatedAt: titleObservedAt }) + ) + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts new file mode 100644 index 00000000000..1534be0deaa --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +const CONNECTION_ID = 'conn-1' +const SSH_HOST_ID: ExecutionHostId = `ssh:${CONNECTION_ID}` +const SSH_REPO_ID = 'ssh-repo' +const SSH_WORKTREE_ID = `${SSH_REPO_ID}::/remote/worktree` +const SSH_PTY_LEFT = `ssh:${CONNECTION_ID}@@pty-left` +const SSH_PTY_RIGHT = `ssh:${CONNECTION_ID}@@pty-right` + +const SSH_REPO = { + id: SSH_REPO_ID, + path: '/remote/worktree', + displayName: 'ssh-repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: CONNECTION_ID +} as const + +function makeSshSnapshot(): RuntimeMobileSessionTabsSnapshot { + const parentLayout = { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: 'left', + ptyIdsByLeafId: { left: SSH_PTY_LEFT, right: SSH_PTY_RIGHT } + } + return { + worktree: SSH_WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: 'tab::left', + activeTabType: 'terminal', + tabGroups: [{ id: 'group', activeTabId: 'tab', tabOrder: ['tab'] }], + tabs: [ + { + type: 'terminal', + id: 'tab::left', + parentTabId: 'tab', + leafId: 'left', + ptyId: SSH_PTY_LEFT, + title: 'Left', + parentLayout, + isActive: true + }, + { + type: 'terminal', + id: 'tab::right', + parentTabId: 'tab', + leafId: 'right', + ptyId: SSH_PTY_RIGHT, + title: 'Right', + parentLayout, + isActive: false + } + ] + } +} + +function makePersistedSshSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [SSH_WORKTREE_ID]: [ + { + id: 'tab', + ptyId: SSH_PTY_LEFT, + worktreeId: SSH_WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: null, + ptyIdsByLeafId: { left: SSH_PTY_LEFT, right: SSH_PTY_RIGHT } + } + } + } +} + +type PartitionedStoreHarness = { + store: never + sessions: Map + writes: { hostId: ExecutionHostId | undefined; session: WorkspaceSessionState }[] + reads: (ExecutionHostId | undefined)[] +} + +/** A store that keeps one workspace session per execution host, like the real one. */ +function partitionedStore(): PartitionedStoreHarness { + const sessions = new Map([ + [LOCAL_EXECUTION_HOST_ID, getDefaultWorkspaceSession()], + [SSH_HOST_ID, makePersistedSshSession()] + ]) + const writes: { hostId: ExecutionHostId | undefined; session: WorkspaceSessionState }[] = [] + const reads: (ExecutionHostId | undefined)[] = [] + const store = { + getRepos: () => [SSH_REPO], + getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined), + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId?: ExecutionHostId) => { + reads.push(hostId) + return sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession() + }, + setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) => { + writes.push({ hostId, session }) + sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session) + }, + flushOrThrow: vi.fn() + } as never + return { store, sessions, writes, reads } +} + +function syncSshSplit(runtime: OrcaRuntimeService, snapshot: RuntimeMobileSessionTabsSnapshot) { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: SSH_WORKTREE_ID, + title: 'Terminal', + activeLeafId: 'left', + layout: + snapshot.tabs[0]?.type === 'terminal' + ? (snapshot.tabs[0].parentLayout?.root ?? null) + : null + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: SSH_WORKTREE_ID, + leafId: 'left', + paneRuntimeId: 1, + ptyId: SSH_PTY_LEFT + }, + { + tabId: 'tab', + worktreeId: SSH_WORKTREE_ID, + leafId: 'right', + paneRuntimeId: 2, + ptyId: SSH_PTY_RIGHT + } + ], + mobileSessionTabs: [snapshot] + }) +} + +describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', () => { + it('retires an exited SSH pane from the SSH partition and leaves the local partition untouched', async () => { + const harness = partitionedStore() + const localBefore = harness.sessions.get(LOCAL_EXECUTION_HOST_ID)! + const runtime = new OrcaRuntimeService(harness.store) + runtime.attachWindow(1) + syncSshSplit(runtime, makeSshSnapshot()) + runtime.registerPty(SSH_PTY_LEFT, SSH_WORKTREE_ID, CONNECTION_ID, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-a' + }) + + runtime.onPtyExit(SSH_PTY_LEFT, 0, 'incarnation-a') + + // The durable retirement must land in the pane's own host partition. + expect(harness.writes.map((write) => write.hostId)).toEqual([SSH_HOST_ID]) + const sshSession = harness.sessions.get(SSH_HOST_ID)! + expect(sshSession.terminalLayoutsByTabId.tab).toMatchObject({ + root: { type: 'leaf', leafId: 'right' }, + ptyIdsByLeafId: { right: SSH_PTY_RIGHT } + }) + expect(sshSession.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: 'tab', ptyId: SSH_PTY_RIGHT }) + ]) + expect(sshSession.terminalTopologyRevisionByRepoId).toMatchObject({ [SSH_REPO_ID]: 1 }) + + // Negative safety: the local partition is a different host and must not move. + expect(harness.sessions.get(LOCAL_EXECUTION_HOST_ID)).toBe(localBefore) + expect(harness.sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalTopologyRevisionByRepoId).toEqual( + localBefore.terminalTopologyRevisionByRepoId + ) + + // And the published surface is actually gone for clients. + expect((await runtime.listMobileSessionTabs(`id:${SSH_WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ id: 'tab::right' }) + ]) + }) + + it('still retires a local pane from the local partition', async () => { + const harness = partitionedStore() + const sshBefore = harness.sessions.get(SSH_HOST_ID)! + const localRepo = { + id: 'local-repo', + path: '/worktree', + displayName: 'local-repo', + badgeColor: 'blue', + addedAt: 1 + } as const + const localWorktreeId = 'local-repo::/worktree' + const localSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [localWorktreeId]: [ + { + id: 'tab', + ptyId: 'pty-left', + worktreeId: localWorktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: null, + ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' } + } + } + } + harness.sessions.set(LOCAL_EXECUTION_HOST_ID, localSession) + const store = { + ...(harness.store as object), + getRepos: () => [SSH_REPO, localRepo], + getRepo: (id: string) => + id === SSH_REPO_ID ? SSH_REPO : id === 'local-repo' ? localRepo : undefined + } as never + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + const snapshot = makeSshSnapshot() + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: localWorktreeId, + title: 'Terminal', + activeLeafId: 'left', + layout: + snapshot.tabs[0]?.type === 'terminal' + ? (snapshot.tabs[0].parentLayout?.root ?? null) + : null + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: localWorktreeId, + leafId: 'left', + paneRuntimeId: 1, + ptyId: 'pty-left' + }, + { + tabId: 'tab', + worktreeId: localWorktreeId, + leafId: 'right', + paneRuntimeId: 2, + ptyId: 'pty-right' + } + ], + mobileSessionTabs: [ + { + ...snapshot, + worktree: localWorktreeId, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' + ? { + ...tab, + ptyId: tab.leafId === 'left' ? 'pty-left' : 'pty-right', + parentLayout: tab.parentLayout + ? { + ...tab.parentLayout, + ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' } + } + : undefined + } + : tab + ) + } + ] + }) + runtime.registerPty('pty-left', localWorktreeId, null, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-a' + }) + + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + + expect(harness.writes.map((write) => write.hostId ?? LOCAL_EXECUTION_HOST_ID)).toEqual([ + LOCAL_EXECUTION_HOST_ID + ]) + expect(harness.sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalLayoutsByTabId.tab).toMatchObject( + { + root: { type: 'leaf', leafId: 'right' } + } + ) + expect(harness.sessions.get(SSH_HOST_ID)).toBe(sshBefore) + expect((await runtime.listMobileSessionTabs(`id:${localWorktreeId}`)).tabs).toEqual([ + expect.objectContaining({ id: 'tab::right' }) + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-retirement.test.ts b/src/main/runtime/orca-runtime-terminal-retirement.test.ts index 9c95156c7cf..342017557cc 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot @@ -531,7 +532,8 @@ describe('OrcaRuntimeService terminal surface retirement', () => { ptyIdsByLeafId: { right: 'pty-right' } }) } - }) + }), + LOCAL_EXECUTION_HOST_ID ) }) @@ -659,7 +661,8 @@ describe('OrcaRuntimeService terminal surface retirement', () => { }, terminalSurfaceTombstonesByPaneKey: {}, terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } - }) + }), + LOCAL_EXECUTION_HOST_ID ) expect(flushOrThrow).toHaveBeenCalledOnce() }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 8f5998313e0..432711bc6cc 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -79,6 +79,7 @@ import { } from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES, @@ -112,7 +113,10 @@ import { } from '../../shared/constants' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { makePaneKey } from '../../shared/stable-pane-id' -import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' +import { + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, + SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV +} from '../../shared/setup-agent-sequencing' import type { AgentSessionExecutionClaim, AgentSessionSurfaceBinding @@ -1410,9 +1414,14 @@ function makeHeadlessTerminalLayout( } } -function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionState): { +function makeRuntimeStoreWithWorkspaceSession( + initialSession: WorkspaceSessionState, + // Why: sessions are partitioned by execution host, so the stub must answer for + // one partition only — a loose stub lets a hardcoded host id pass unnoticed. + ownerHostId = 'local' +): { runtimeStore: typeof store & { - getWorkspaceSession: () => WorkspaceSessionState + getWorkspaceSession: (hostId?: string) => WorkspaceSessionState setWorkspaceSession: ReturnType persistPtyBinding: ReturnType } @@ -1425,7 +1434,8 @@ function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionSt } const runtimeStore = { ...store, - getWorkspaceSession: () => session, + getWorkspaceSession: (hostId?: string) => + hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), setWorkspaceSession: vi.fn(setSession), persistPtyBinding: vi.fn( (args: { worktreeId: string; tabId: string; leafId: string; ptyId: string }) => { @@ -5674,7 +5684,9 @@ describe('OrcaRuntimeService', () => { 1, expect.objectContaining({ cwd: '/remote/mobile-setup', - command: expect.stringContaining('exec claude'), + env: expect.objectContaining({ + [SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: expect.stringContaining('exec claude') + }), worktreeId: result.worktree.id }) ) @@ -5688,14 +5700,20 @@ describe('OrcaRuntimeService', () => { worktreeId: result.worktree.id }) ) - const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const startup = spawn.mock.calls[0]![0] as { + command: string + env: Record + } + const startupCommand = startup.command + const startupScript = startup.env[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]! const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command - const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + const nonceMatch = startupScript.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) expect(nonceMatch?.[1]).toBeTruthy() const markerPath = `/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh.${nonceMatch![1]}.done` + expect(startupCommand.length).toBeLessThan(256) expect(setupCommand).toContain('printf') expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) - expect(startupCommand).toContain(markerPath) + expect(startupScript).toContain(markerPath) expect(setupCommand).toContain(markerPath) expect(revealTerminalSession).toHaveBeenLastCalledWith( result.worktree.id, @@ -8817,6 +8835,99 @@ describe('OrcaRuntimeService', () => { expect(runtime.getPtyOutputSequence('pty-1')).toBe(0) }) + it('confirms title-based agent exits against the foreground process', async () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + runtime.onPtyData('pty-1', '\x1b]0;⠋ bichir\x07', 100) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + batches.length = 0 + + const getForegroundProcess = vi.fn().mockResolvedValueOnce('codex') + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess + }) + runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 101) + + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce()) + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'title', normalizedTitle: 'bichir', rawTitle: 'bichir' } + ]) + ) + await vi.waitFor(() => + expect( + ( + runtime as unknown as { + ptyForegroundProcessReads: Map + } + ).ptyForegroundProcessReads.size + ).toBe(0) + ) + await Promise.resolve() + + getForegroundProcess.mockResolvedValueOnce('zsh') + runtime.onPtyData('pty-1', '\x1b]0;other cwd\x07', 102) + + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + ) + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + }) + + it('does not confirm an agent exit from a foreground read predating its title', async () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + let resolveStaleRead!: (process: string) => void + const staleRead = new Promise((resolve) => { + resolveStaleRead = resolve + }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleRead) + .mockResolvedValueOnce('zsh') + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess + }) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) + expect(getForegroundProcess).toHaveBeenCalledOnce() + + resolveStaleRead('codex') + + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + ) + }) + + it('treats synchronous foreground read failures as unavailable', async () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + const getForegroundProcess = vi.fn(() => { + throw new TypeError('getForegroundProcess is unavailable') + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess + }) + + runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) + + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + ) + expect(getForegroundProcess).toHaveBeenCalledOnce() + }) + it('aligns a restored session and pre-response bytes to the provider sequence', async () => { const { runtime } = createSideEffectRuntime() @@ -13837,6 +13948,212 @@ describe('OrcaRuntimeService', () => { ) }) + // Why (#10333): `orca serve` publishes a ready graph under + // HEADLESS_RUNTIME_WINDOW_ID with no BrowserWindow behind it, so every + // focus-requested create used to fall through to getAuthoritativeWindow(). + const wireHeadlessServeRuntime = (): OrcaRuntimeService => { + const runtime = new OrcaRuntimeService(store) + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + return runtime + } + + it('spawns focus-requested CLI terminal creates in background on headless serve', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-focus-headless' }) + const runtime = wireHeadlessServeRuntime() + const revealTerminalSession = vi.fn() + 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(), + resumeSleepingAgents: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + // `orca terminal create --worktree --command "echo test" --focus` + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'echo test', + focus: true + }) + ).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + handle: expect.stringMatching(/^term_/) + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'echo test', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + persistHostSessionBinding: true + }) + ) + // Why: degrading the create must not silently drop the focus request — the + // host still asks whatever surface exists to activate the new pane. + expect(revealTerminalSession).toHaveBeenCalledWith( + TEST_WORKTREE_ID, + expect.objectContaining({ activate: true, presentation: 'focused' }) + ) + }) + + it('spawns presentation:focused terminal creates in background on headless serve', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-presentation-headless' }) + const runtime = wireHeadlessServeRuntime() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + // Paired desktop `+` button: clients send presentation:'focused', not focus. + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + presentation: 'focused' + }) + ).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + surface: 'background', + handle: expect.stringMatching(/^term_/) + }) + expect(spawn).toHaveBeenCalled() + }) + + it('spawns focused agent-session creates in background on headless serve', async () => { + // Why: RPC only downgrades `focused` for clients that report a clientKind + // (#10193), so loopback/CLI callers still reach the runtime asking for focus. + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-headless' }) + const runtime = wireHeadlessServeRuntime() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createAgentSession({ + clientOperationId: `${Date.now()}-0123456789abcdef0123456789abcdef`, + worktree: `path:${TEST_WORKTREE_PATH}`, + agent: 'codex', + prompt: 'hello', + presentation: 'focused' + }) + ).resolves.toMatchObject({ + disposition: 'created', + terminal: expect.objectContaining({ + worktreeId: TEST_WORKTREE_ID, + handle: expect.stringMatching(/^term_/) + }) + }) + expect(spawn).toHaveBeenCalled() + }) + + it('activates the paired mobile session tab for a degraded focused headless create', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-focus-publish' }) + const runtime = wireHeadlessServeRuntime() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'echo test', + focus: true + }) + + // Why: with no renderer notifier on a serve host, the session-tab publish is + // the only channel a paired client learns about the terminal on — a degraded + // focused create must still land there selected, or focus is silently lost. + const tabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const published = tabs.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === created.tabId + ) + expect(published).toBeDefined() + expect(published?.isActive).toBe(true) + expect(tabs.activeTabId).toBe(published?.id) + }) + + it('keeps focus-requested terminal creates on the renderer when a window exists', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-should-not-spawn' }) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-focused', + worktreeId: TEST_WORKTREE_ID, + title: 'Focused Terminal', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-focused', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-focused', + paneTitle: null + } + ] + }) + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-focused', title: 'Focused Terminal' } + ) + }) + webContents.send = send + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + command: 'echo test', + focus: true + }) + ).resolves.toMatchObject({ + tabId: 'tab-focused', + worktreeId: TEST_WORKTREE_ID, + surface: 'visible' + }) + expect(send).toHaveBeenCalledWith( + 'terminal:requestTabCreate', + expect.objectContaining({ activate: true, presentation: 'focused' }) + ) + expect(spawn).not.toHaveBeenCalled() + }) + it('accepts renderer-backed terminal create replies only from the target renderer', async () => { const webContents = { send: vi.fn() } const send = vi.fn((_channel: string, payload: { requestId: string }) => { @@ -29134,6 +29451,293 @@ describe('OrcaRuntimeService', () => { }) }) + describe('deliberately parked pane activation (STA-3465)', () => { + function makeParkedSessionStore( + origin: SleepingAgentSessionRecord['origin'] | undefined, + overrides: Partial = {}, + ownerHostId = 'local' + ) { + return makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + sleepingAgentSessionsByPaneKey: { + [`host-tab:${HEADLESS_LEAF_ID}`]: { + paneKey: `host-tab:${HEADLESS_LEAF_ID}`, + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session-1' }, + prompt: 'do the thing', + state: 'done', + capturedAt: 1, + updatedAt: 1, + ...(origin ? { origin } : {}), + ...overrides + } as SleepingAgentSessionRecord + } + }), + ownerHostId + ) + } + + function makeParkedRuntime(runtimeStore: unknown): { + runtime: OrcaRuntimeService + spawn: ReturnType + } { + const spawn = vi.fn().mockResolvedValue({ id: 'persisted-pty' }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + return { runtime, spawn } + } + + function setParkedRuntimeNotifier( + runtime: OrcaRuntimeService, + resumeSleepingAgents: (worktreeId: string) => void + ): void { + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + } + + const userActivate = ( + runtime: OrcaRuntimeService, + leafId?: string + ): Promise => + runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', leafId, { + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + + const automaticActivate = ( + runtime: OrcaRuntimeService, + leafId?: string + ): Promise => + runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', leafId, { + notifyClients: false, + navigation: 'caller', + intent: 'automatic' + }) + + it('refuses an automatic reconnect probe for a deliberately slept pane', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ + type: 'terminal', + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + status: 'pending-handle', + terminal: null + }) + // Negative safety: refusing to wake must not retire the surface either. + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject( + { parentTabId: 'host-tab', status: 'pending-handle' } + ) + }) + + it('refuses an automatic probe that carries the leafId the probe sends', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime, HEADLESS_LEAF_ID) + + expect(spawn).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + }) + + // Why: opening the tab is the documented wake gesture for a slept pane + // (#11598). These four cover every topology, because three of them never + // clear the record — the pane's own activation is the only thing that wakes it. + it('materializes a slept pane for a user tap under headless serve, which never wakes', async () => { + const { runtimeStore, getSession } = makeParkedSessionStore('worktree-sleep') + const resumeSleepingAgents = vi.fn() + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + setParkedRuntimeNotifier(runtime, resumeSleepingAgents) + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + + const worktreeActivation = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + expect(worktreeActivation.sleepingAgentWake).toBe('unsupported-headless') + expect(resumeSleepingAgents).not.toHaveBeenCalled() + expect( + getSession().sleepingAgentSessionsByPaneKey?.[`host-tab:${HEADLESS_LEAF_ID}`]?.origin + ).toBe('worktree-sleep') + + const activated = await userActivate(runtime, HEADLESS_LEAF_ID) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('materializes a slept pane for a paired desktop client tab click, which asks for no wake', async () => { + const { runtimeStore, getSession } = makeParkedSessionStore('worktree-sleep') + const resumeSleepingAgents = vi.fn() + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + setParkedRuntimeNotifier(runtime, resumeSleepingAgents) + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'runtime' + }) + + expect(resumeSleepingAgents).not.toHaveBeenCalled() + expect( + getSession().sleepingAgentSessionsByPaneKey?.[`host-tab:${HEADLESS_LEAF_ID}`] + ).toBeDefined() + + const activated = await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: manual sleep of a finished agent stamps restoreOnTabOpenOnly, which the + // background wake skips and resume classifies pane-owned, so the record survives. + it('materializes a slept pane whose completed-agent record is restore-on-tab-open-only', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', { + state: 'done', + restoreOnTabOpenOnly: true + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: manual sleep of a running agent is the one topology whose wake relaunches + // and clears the record, so the pane must materialize with the record gone too. + it('materializes a slept running-agent pane after its wake cleared the record', async () => { + const { runtimeStore, getSession, setSession } = makeParkedSessionStore('worktree-sleep', { + state: 'working' + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + const woken = structuredClone(getSession()) + delete woken.sleepingAgentSessionsByPaneKey?.[`host-tab:${HEADLESS_LEAF_ID}`] + setSession(woken) + + const activated = await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: the field is additive, so a client that predates it sends nothing and + // must keep its wake gesture rather than silently losing it. + it('treats an absent intent as a user activation', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + 'host-tab', + undefined, + { notifyClients: false, navigation: 'caller' } + ) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: #11542's reconnect fix depends on an automatic activate materializing a + // genuinely awaiting pane. These four prove the park guard did not break it. + it('still materializes a pane awaiting reconnect with no sleeping record', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + sessionId: 'persisted-pty' + }) + ) + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('still materializes a pane whose record was captured while it was live', async () => { + const { runtimeStore } = makeParkedSessionStore('live') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('still materializes a pane whose record was captured at app quit', async () => { + const { runtimeStore } = makeParkedSessionStore('quit') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('ignores a park record that belongs to a different worktree', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', { + worktreeId: 'other-repo::/other' + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + const activated = await automaticActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + // Why: sleeping records live in the owning execution host's session partition, + // so reading a fixed partition would miss the record on an SSH-host worktree. + it('reads the park record from the worktree own execution-host partition', async () => { + const sshRepo = { ...store.getRepos()[0]!, executionHostId: 'ssh:ssh-1' as const } + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', {}, 'ssh:ssh-1') + const { runtime, spawn } = makeParkedRuntime({ + ...runtimeStore, + getRepos: () => [sshRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? sshRepo : undefined) + }) + + const activated = await automaticActivate(runtime) + + expect(spawn).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ status: 'pending-handle', terminal: null }) + }) + }) + it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ @@ -29156,7 +29760,8 @@ describe('OrcaRuntimeService', () => { [HEADLESS_LEAF_ID]: 'ssh:ssh-1@@relay-pty' }) } - }) + }), + 'ssh:ssh-1' ) const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } const remoteStore = { @@ -29209,7 +29814,8 @@ describe('OrcaRuntimeService', () => { terminalLayoutsByTabId: { 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: stalePtyId }) } - }) + }), + 'ssh:ssh-1' ) const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } const remoteStore = { @@ -29435,7 +30041,8 @@ describe('OrcaRuntimeService', () => { terminalLayoutsByTabId: { 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: undefined }) } - }) + }), + 'ssh:ssh-1' ) const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } const remoteStore = { @@ -39833,12 +40440,18 @@ describe('OrcaRuntimeService', () => { ) // Why: setup is provisioned fire-and-forget; the wait-for-setup guarantee comes from the shell nonce/marker, not JS spawn ordering. await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) - const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const startup = spawn.mock.calls[0]![0] as { + command: string + env: Record + } + const startupCommand = startup.command + const startupScript = startup.env[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]! const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command - const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + const nonceMatch = startupScript.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) expect(nonceMatch?.[1]).toBeTruthy() - expect(startupCommand).toContain('exec claude') - expect(startupCommand).toContain('/mnt/c/tmp/repo/.git/orca/setup-runner.sh') + expect(startupCommand.length).toBeLessThan(256) + expect(startupScript).toContain('exec claude') + expect(startupScript).toContain('/mnt/c/tmp/repo/.git/orca/setup-runner.sh') expect(setupCommand).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') expect(setupCommand).toContain('printf') expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) @@ -41117,7 +41730,9 @@ describe('OrcaRuntimeService', () => { 1, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: expect.stringContaining('codex'), + env: expect.objectContaining({ + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: expect.stringContaining('codex') + }), worktreeId: result.worktree.id }) ) @@ -41134,15 +41749,21 @@ describe('OrcaRuntimeService', () => { worktreeId: result.worktree.id }) ) - const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const startup = spawn.mock.calls[0]![0] as { + command: string + env: Record + } + const startupCommand = startup.command + const startupScript = startup.env[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]! const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command - const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + const nonceMatch = startupScript.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) expect(nonceMatch?.[1]).toBeTruthy() const markerPath = `/tmp/repo/.git/orca/setup-runner.sh.${nonceMatch![1]}.done` - expect(startupCommand).toContain('--dangerously-bypass-approvals-and-sandbox') + expect(startupCommand.length).toBeLessThan(256) + expect(startupScript).toContain('--dangerously-bypass-approvals-and-sandbox') expect(setupCommand).toContain('printf') expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) - expect(startupCommand).toContain(markerPath) + expect(startupScript).toContain(markerPath) expect(setupCommand).toContain(markerPath) const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a4d343d5351..1a2aed7a74b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10,6 +10,7 @@ import { normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' +import { sortDirEntries } from '../../shared/file-name-sort' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' import { parseFileUriPathParts } from '../daemon/osc7-file-uri' @@ -245,6 +246,10 @@ import { navigationTargetsHost, type RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import { + isAutomaticTabActivation, + type TabActivationIntent +} from '../../shared/tab-activation-intent' import type { SshConnectionState } from '../../shared/ssh-types' import { getPublicSshState } from './public-ssh-state' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' @@ -1361,6 +1366,18 @@ type PtyForegroundAgentRefresh = { requestedAfterTitleObservation: number } +type PtyForegroundProcessRead = { + controller: RuntimePtyController + process: string | null + available: boolean +} + +type PtyForegroundProcessReadEntry = { + controller: RuntimePtyController + startedAfterTitleObservation: number + promise: Promise +} + function copySleepingAgentLaunchConfig( config: SleepingAgentLaunchConfig ): SleepingAgentLaunchConfig { @@ -2869,6 +2886,7 @@ export class OrcaRuntimeService { private cloneInFlightByPath = new Map>() private agentDetector: AgentDetector | null = null private ptyForegroundAgentRefreshes = new Map() + private ptyForegroundProcessReads = new Map() private ptyDelayedForegroundSnapshotTitleObservations = new Map() private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() @@ -6671,6 +6689,69 @@ export class OrcaRuntimeService { return next } + /** + * Retires each surface in the session partition of the host that owns its worktree. + * Why: an SSH pane's durable surface lives in that connection's partition; retiring it + * against the local partition strands the real ghost and bumps a foreign host's epoch. + * Returns null when nothing may be published because persistence is unavailable or failed. + */ + private persistTerminalSurfaceRetirements( + retiredSurfaces: readonly RetiredTerminalSurface[] + ): { accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null { + const surfacesByHostId = new Map() + for (const surface of retiredSurfaces) { + const hostId = + this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? LOCAL_EXECUTION_HOST_ID + const bucket = surfacesByHostId.get(hostId) + if (bucket) { + bucket.push(surface) + } else { + surfacesByHostId.set(hostId, [surface]) + } + } + const accepted: RetiredTerminalSurface[] = [] + const unpersisted: RetiredTerminalSurface[] = [] + const pendingWrites: { hostId: ExecutionHostId; session: WorkspaceSessionState }[] = [] + for (const [hostId, surfaces] of surfacesByHostId) { + const session = this.store?.getWorkspaceSession?.(hostId) + if (!session) { + unpersisted.push(...surfaces) + continue + } + // Why: publishing absence before its host membership fence is durable lets a crash or + // stale renderer write resurrect the retired surface. + if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { + return null + } + let nextSession = session + const acceptedForHost: RetiredTerminalSurface[] = [] + for (const surface of surfaces) { + const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) + if (candidate !== nextSession) { + acceptedForHost.push(surface) + nextSession = candidate + } + } + if (acceptedForHost.length === 0) { + continue + } + accepted.push(...acceptedForHost) + pendingWrites.push({ hostId, session: nextSession }) + } + if (pendingWrites.length > 0) { + try { + for (const write of pendingWrites) { + this.store?.setWorkspaceSession?.(write.session, write.hostId) + } + this.store?.flushOrThrow?.() + } catch (error) { + console.error('[runtime] failed to persist terminal retirement:', error) + return null + } + } + return { accepted, unpersisted } + } + private retireMobileSessionSurfacesForPty( ptyId: string, incarnationId: string, @@ -6704,43 +6785,21 @@ export class OrcaRuntimeService { if (retiredSurfaces.length === 0) { return } - let publishableRetiredSurfaces = retiredSurfaces - const session = this.store?.getWorkspaceSession?.() - if (session) { - // Why: publishing absence before its host membership fence is durable lets a crash or - // stale renderer write resurrect the retired surface. - if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { - return - } - let nextSession = session - const acceptedSurfaces: RetiredTerminalSurface[] = [] - for (const surface of retiredSurfaces) { - const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) - if (candidate !== nextSession) { - acceptedSurfaces.push(surface) - nextSession = candidate - } - } - if (acceptedSurfaces.length === 0) { - return - } - try { - this.store.setWorkspaceSession(nextSession) - this.store.flushOrThrow() - } catch (error) { - console.error('[runtime] failed to persist terminal retirement:', error) - return - } - // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. - publishableRetiredSurfaces = acceptedSurfaces - } else { - for (const surface of retiredSurfaces) { - const repoId = getRepoIdFromWorktreeId(surface.worktreeId) - this.terminalTopologyRevisionByRepoId.set( - repoId, - (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 - ) - } + const persisted = this.persistTerminalSurfaceRetirements(retiredSurfaces) + if (!persisted) { + return + } + for (const surface of persisted.unpersisted) { + const repoId = getRepoIdFromWorktreeId(surface.worktreeId) + this.terminalTopologyRevisionByRepoId.set( + repoId, + (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 + ) + } + // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. + const publishableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] + if (publishableRetiredSurfaces.length === 0) { + return } for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { const retired = retireTerminalSurfacesFromSnapshot({ @@ -7280,6 +7339,7 @@ export class OrcaRuntimeService { notifyClients?: boolean clientNavigationId?: string navigation?: RuntimeNavigationTarget + intent?: TabActivationIntent } = {} ): Promise { const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') @@ -7321,6 +7381,10 @@ export class OrcaRuntimeService { const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && + // Why: opening a tab is the documented wake gesture for a slept pane + // (#11598), so only a background probe may be refused for one. + (!isAutomaticTabActivation(opts.intent) || + !this.isDeliberatelyParkedPane(worktreeId, tab)) && (!targetsHost || !this.notifier?.focusTerminal || this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) @@ -7474,6 +7538,29 @@ export class OrcaRuntimeService { return snapshot } + /** + * Whether persistence proves this pane's PTY was deliberately taken down and parked + * (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect. + * Why: `pending-handle` alone cannot tell those apart — a parked pane publishes it + * indefinitely — and respawning a parked pane re-launches its agent behind the user. + * Only an automatic activation consults this; a user opening the tab is the wake gesture. + */ + private isDeliberatelyParkedPane( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const record = + this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[ + makePaneKey(tab.parentTabId, tab.leafId) + ] + // Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect + // must still mint its replacement PTY (#11542). Only a worktree-owned capture records + // a deliberate takedown the user did not ask to undo. + return ( + record?.origin === 'worktree-sleep' && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId) + ) + } + private shouldMaterializeHeadlessMobileSessionTab( snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab @@ -8819,6 +8906,20 @@ export class OrcaRuntimeService { return undefined } return store.getWorktreeMeta(worktreeId)?.linkedIssue ?? null + }, + getWorktreeLinkedIssueMeta: (worktreeId) => { + const store = this.store + if (!store?.getWorktreeMeta) { + return undefined + } + const meta = store.getWorktreeMeta(worktreeId) + return meta + ? { + linkedIssue: meta.linkedIssue, + linkedGitLabIssue: meta.linkedGitLabIssue, + linkedWorkItem: meta.linkedWorkItem + } + : null } }) @@ -10067,7 +10168,7 @@ export class OrcaRuntimeService { }) }, onAgentExited: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + this.confirmPtyAgentExit(ptyId) }, onCommandFinished: (exitCode: number | null) => { this.retirePtyAgentLaunchAuthority(ptyId) @@ -10662,10 +10763,7 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if ( - this.spawnPublishedPtys.has(ptyId) || - this.pendingPtyRegistrationIncarnations.has(ptyId) - ) { + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { return false } // SSH panes have their own lease/reattach machinery. @@ -16558,6 +16656,99 @@ export class OrcaRuntimeService { ) } + private readPtyForegroundProcessFromController( + ptyId: string, + afterTitleObservation = 0 + ): Promise | null { + const controller = this.ptyController + if (!controller) { + return null + } + const pending = this.ptyForegroundProcessReads.get(ptyId) + if ( + pending?.controller === controller && + pending.startedAfterTitleObservation >= afterTitleObservation + ) { + return pending.promise + } + if (pending?.controller === controller) { + return pending.promise.then( + () => + this.readPtyForegroundProcessFromController(ptyId, afterTitleObservation) ?? { + controller, + process: null, + available: false + } + ) + } + const unavailable: PtyForegroundProcessRead = { + controller, + process: null, + available: false + } + let processRead: Promise + try { + processRead = Promise.resolve(controller.getForegroundProcess(ptyId)) + } catch { + const entry: PtyForegroundProcessReadEntry = { + controller, + startedAfterTitleObservation: afterTitleObservation, + promise: Promise.resolve(unavailable) + } + entry.promise = entry.promise.finally(() => { + if (this.ptyForegroundProcessReads.get(ptyId) === entry) { + this.ptyForegroundProcessReads.delete(ptyId) + } + }) + this.ptyForegroundProcessReads.set(ptyId, entry) + return entry.promise + } + let entry: PtyForegroundProcessReadEntry + const promise = processRead + .then((process) => ({ controller, process, available: true })) + .catch(() => unavailable) + .finally(() => { + if (this.ptyForegroundProcessReads.get(ptyId) === entry) { + this.ptyForegroundProcessReads.delete(ptyId) + } + }) + entry = { + controller, + startedAfterTitleObservation: afterTitleObservation, + promise + } + this.ptyForegroundProcessReads.set(ptyId, entry) + return entry.promise + } + + private confirmPtyAgentExit(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + const titleObservedAt = pty?.lastOscTitleAt ?? null + const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) + if (!pty?.connected || !foregroundRead) { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + return + } + void foregroundRead.then((result) => { + const current = this.ptysById.get(ptyId) + if (current !== pty || !current.connected) { + return + } + if (current.lastOscTitleAt !== titleObservedAt && current.lastAgentStatus !== null) { + return + } + if ( + result.controller === this.ptyController && + result.available && + recognizeAgentProcess(result.process) !== null + ) { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + return + } + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + }) + } + /** * Schedules an asynchronous query to check which agent process is currently * running in the foreground of a PTY. @@ -16620,7 +16811,10 @@ export class OrcaRuntimeService { const refresh = (async (): Promise => { while (true) { entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation - const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController(ptyId) + const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController( + ptyId, + entry.startedAfterTitleObservation + ) if ( foregroundAgentChanged || entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation @@ -16642,7 +16836,10 @@ export class OrcaRuntimeService { * Queries the PTY controller for the active foreground process, identifies if it * is a recognized agent, and updates the PTY's foreground agent state if changed. */ - private async loadPtyForegroundAgentFromController(ptyId: string): Promise { + private async loadPtyForegroundAgentFromController( + ptyId: string, + afterTitleObservation = 0 + ): Promise { if (!this.ptyController) { return false } @@ -16656,12 +16853,15 @@ export class OrcaRuntimeService { if (pty.launchAgent) { return false } - let foregroundProcess: string | null - try { - foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) - } catch { + const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, afterTitleObservation) + if (!foregroundRead) { return false } + const result = await foregroundRead + if (result.controller !== this.ptyController || !result.available) { + return false + } + const foregroundProcess = result.process const foregroundAgent = foregroundProcess ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) : null @@ -18051,12 +18251,7 @@ export class OrcaRuntimeService { isDirectory: entry.isDirectory(), isSymlink: entry.isSymbolicLink() })) - mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + sortDirEntries(mapped) return { resolvedPath: dirPath, entries: mapped, @@ -24859,9 +25054,12 @@ export class OrcaRuntimeService { (Boolean(opts.agentSessionClaim) || (!requiresRendererFocus && opts.rendererBacked !== true) || // Why: `orca serve` exposes the local runtime without a renderer - // window. Renderer-backed Codex terminals are preferred for the app, - // but headless CLI users still need a usable terminal handle. - (opts.rendererBacked === true && rendererWindow === null)) + // window. Renderer-backed and focus-requested creates are preferred on + // the renderer, but with no window a background spawn is the only + // usable path — otherwise getAuthoritativeWindow() below throws and the + // caller gets no terminal at all (#10333). Focus is not lost: the + // spawned pane is still published and revealed with `activate`. + availableAuthoritativeWindow === null) if (shouldCreateInBackground) { if (!this.ptyController?.spawn) { @@ -29941,7 +30139,6 @@ export class OrcaRuntimeService { ownerAgent ) const liveTitleEvidence = leafTitle ?? ptyTitle - const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence) // Why: renderer status can precede hook session identity, leaving native chat with no transcript address. const rendererStatusAgent = resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent) ?? @@ -29973,11 +30170,14 @@ export class OrcaRuntimeService { const hasLiveAgentSignal = normalizedTabAgentStatus?.interactivePrompt != null || normalizedTabAgentStatus?.toolName != null + // Why: only shell/management evidence proves the agent released the pane + // (same predicate as the terminal-status API). A merely neutral live title + // — 'Terminal', an editor, a cwd — proves nothing, and treating it as + // completion published a synthetic `done` that fought the client's own + // live status on every republication. const keepFullAgentStatus = normalizedTabAgentStatus && - (liveTitleEvidence === null || - liveTitleEvidenceClassification === 'agent' || - hasLiveAgentSignal) + (!terminalTitleBlocksExplicitAgentStatus(liveTitleEvidence) || hasLiveAgentSignal) const agentStatus = keepFullAgentStatus ? { agentStatus: normalizedTabAgentStatus } : // Why: idle live title → drop stale "working" (no spinner) but keep agent identity so native chat can still address the transcript. @@ -30172,9 +30372,12 @@ export class OrcaRuntimeService { } } // Last resort: the pane's hook evidence is identity only (resume rows, stale - // rows, or a row the freshness gate rejected). `done` with a now-stamp is the - // honest projection — and it is what retires the card once the agent exits. - const now = pty?.lastOutputAt ?? Date.now() + // rows, or a row the freshness gate rejected). `done` is the honest + // projection — and it is what retires the card once the agent exits. + // Why not lastOutputAt: this state is title-derived, so it must be dated by + // its evidence. Stamping it with the byte stream made the frame advance on + // every output byte, so a paired client's live status could never outrank it. + const evidenceAt = pty?.lastOscTitleEpochMs ?? hookRow.providerSessionReceivedAt ?? Date.now() const agentType = ownerAgent ?? undefined return { agentStatus: { @@ -30185,8 +30388,8 @@ export class OrcaRuntimeService { ? 'blocked' : 'done', prompt: '', - updatedAt: now, - stateStartedAt: now, + updatedAt: evidenceAt, + stateStartedAt: evidenceAt, paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), diff --git a/src/main/runtime/remote-browser-screencast-frame-admission.test.ts b/src/main/runtime/remote-browser-screencast-frame-admission.test.ts new file mode 100644 index 00000000000..8f0f1e83081 --- /dev/null +++ b/src/main/runtime/remote-browser-screencast-frame-admission.test.ts @@ -0,0 +1,149 @@ +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' + +import { deriveSharedKey, encrypt, generateKeyPair } from '../../shared/e2ee-crypto' +import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits' +import { startBrowserScreencast } from '../browser/browser-screencast-stream' +import { E2EEChannel } from './rpc/e2ee-channel' +import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' + +function createMockWebContents() { + let attached = false + const dbg = new EventEmitter() as EventEmitter & { + isAttached: ReturnType + attach: ReturnType + detach: ReturnType + sendCommand: ReturnType + } + dbg.isAttached = vi.fn(() => attached) + dbg.attach = vi.fn(() => { + attached = true + }) + dbg.detach = vi.fn(() => { + attached = false + }) + dbg.sendCommand = vi.fn(async () => ({})) + return { isDestroyed: vi.fn(() => false), debugger: dbg } +} + +// Why: exercises the real E2EE channel so the oracle fails if an over-limit frame ever reaches +// the transport that answers it with close code 1013. +function createRuntimeBinarySender() { + const serverKeys = generateKeyPair() + const clientKeys = generateKeyPair() + const ws = { + OPEN: 1 as const, + readyState: 1, + send: vi.fn(), + close: vi.fn() + } + const onTransportError = vi.fn((code: number, reason: string) => ws.close(code, reason)) + const channel = new E2EEChannel(ws as never, { + serverSecretKey: serverKeys.secretKey, + resolveAuthenticatedDevice: (token) => + token === 'valid-token' + ? { deviceId: 'device-1', deviceToken: token, scope: 'runtime' } + : null, + onReady: vi.fn(), + onError: onTransportError + }) + const sharedKey = deriveSharedKey(clientKeys.secretKey, serverKeys.publicKey) + channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(clientKeys.publicKey).toString('base64') + }) + ) + channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + ) + let sendBinary: ((bytes: Uint8Array) => boolean | void) | undefined + channel.onMessage((_plaintext, _sendText, sendBinaryReply) => { + sendBinary = sendBinaryReply + }) + channel.handleRawMessage(encrypt('start-screencast', sharedKey)) + if (!sendBinary) { + throw new Error('Runtime binary sender was not established') + } + return { channel, ws, onTransportError, sendBinary } +} + +describe('sendRemoteBrowserScreencastFrame', () => { + it('reports an over-limit frame as handled so the producer does not retry it', () => { + const sendBinary = vi.fn(() => true) + const oversized = new Uint8Array(REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES + 1) + + expect(sendRemoteBrowserScreencastFrame(sendBinary, oversized)).toBe(true) + expect(sendBinary).not.toHaveBeenCalled() + }) + + it('still forwards backpressure for a frame the transport can accept', () => { + const withinLimit = new Uint8Array(1024) + + expect( + sendRemoteBrowserScreencastFrame( + vi.fn(() => false), + withinLimit + ) + ).toBe(false) + expect( + sendRemoteBrowserScreencastFrame( + vi.fn(() => true), + withinLimit + ) + ).toBe(true) + expect(sendRemoteBrowserScreencastFrame(vi.fn(), withinLimit)).toBe(true) + }) + + it('drops an oversized encoded frame without closing or stalling the paired stream', async () => { + vi.useFakeTimers() + const webContents = createMockWebContents() + const transport = createRuntimeBinarySender() + const session = await startBrowserScreencast(webContents as never, { + format: 'jpeg', + quality: 70, + maxWidth: 3840, + maxHeight: 2160, + everyNthFrame: 1, + minFrameIntervalMs: 0, + onFrame: (bytes) => sendRemoteBrowserScreencastFrame(transport.sendBinary, bytes) + }) + + try { + webContents.debugger.emit('message', {}, 'Page.screencastFrame', { + data: Buffer.alloc(REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES + 1).toString('base64'), + sessionId: 42, + metadata: {} + }) + await Promise.resolve() + + expect(transport.onTransportError).not.toHaveBeenCalled() + expect(transport.ws.close).not.toHaveBeenCalled() + expect(webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', { + sessionId: 42 + }) + const sendsAfterDrop = transport.ws.send.mock.calls.length + await vi.advanceTimersByTimeAsync(500) + expect(transport.ws.send).toHaveBeenCalledTimes(sendsAfterDrop) + + webContents.debugger.emit('message', {}, 'Page.screencastFrame', { + data: Buffer.from('next-frame').toString('base64'), + sessionId: 43, + metadata: {} + }) + await Promise.resolve() + + expect(transport.onTransportError).not.toHaveBeenCalled() + expect(webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', { + sessionId: 43 + }) + expect(transport.ws.send.mock.calls.length).toBeGreaterThan(sendsAfterDrop) + } finally { + session.stop() + await session.done + transport.channel.destroy() + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/remote-browser-screencast-frame-admission.ts b/src/main/runtime/remote-browser-screencast-frame-admission.ts new file mode 100644 index 00000000000..6e4d1255b2a --- /dev/null +++ b/src/main/runtime/remote-browser-screencast-frame-admission.ts @@ -0,0 +1,14 @@ +import { isRemoteRuntimeBinaryFrameWithinLimit } from '../../shared/remote-runtime-memory-limits' + +// Why: the E2EE channel closes the socket (1013) on an over-limit binary frame, and the +// screencast producer reads `false` as backpressure and retries the identical frame. Reporting +// an over-limit frame as handled drops it so the stream advances instead of retrying forever. +export function sendRemoteBrowserScreencastFrame( + sendBinary: (bytes: Uint8Array) => boolean | void, + bytes: Uint8Array +): boolean { + if (!isRemoteRuntimeBinaryFrameWithinLimit(bytes)) { + return true + } + return sendBinary(bytes) !== false +} diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 9eff1456662..a9a2a440f8f 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -152,7 +152,8 @@ export const ProfileCreate = z.object({ label: requiredString('Missing required --label'), // Strict enum so unknown scope values surface validation errors instead of being // silently coerced to 'isolated' (pr-bug-scan finding from #1397). - scope: z.enum(['isolated', 'imported']) + scope: z.enum(['isolated', 'imported']), + userAgentMode: z.enum(['clean', 'native']).optional() }) export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') }) diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index f17359d524a..57095000146 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -10,13 +10,25 @@ import { import { BROWSER_CORE_METHODS } from './browser-core' import { BROWSER_EXTRA_METHODS } from './browser-extras' import { BROWSER_SCREENCAST_METHODS } from './browser-screencast' -import { ClipboardWrite, Fill, KeyboardInsert, Type } from './browser-schemas' +import { ClipboardWrite, Fill, KeyboardInsert, ProfileCreate, Type } from './browser-schemas' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } } describe('browser RPC methods', () => { + it('validates profile user-agent modes', () => { + expect( + ProfileCreate.safeParse({ label: 'Google', scope: 'isolated', userAgentMode: 'native' }) + .success + ).toBe(true) + expect(ProfileCreate.safeParse({ label: 'Work', scope: 'isolated' }).success).toBe(true) + expect( + ProfileCreate.safeParse({ label: 'Bad', scope: 'isolated', userAgentMode: 'rotating' }) + .success + ).toBe(false) + }) + it('routes core browser automation commands to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts b/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts new file mode 100644 index 00000000000..2055222ff7f --- /dev/null +++ b/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('files.resolveTerminalPath RPC', () => { + function createDispatcher() { + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveTerminalPath: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + relativePath: 'src/index.ts', + exists: true, + isDirectory: false + }) + } as unknown as OrcaRuntimeService + return { runtime, dispatcher: new RpcDispatcher({ runtime, methods: FILE_METHODS }) } + } + + it('resolves a tapped terminal path for a selected worktree', async () => { + const { runtime, dispatcher } = createDispatcher() + + const response = await dispatcher.dispatch( + makeRequest('files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: '/repo/src/index.ts', + cwd: '/repo', + terminal: 'term-1' + }) + ) + + expect(runtime.resolveTerminalPath).toHaveBeenCalledWith( + 'id:wt-1', + '/repo/src/index.ts', + '/repo', + undefined, + 'term-1', + false + ) + expect(response).toMatchObject({ + ok: true, + result: { relativePath: 'src/index.ts', exists: true, isDirectory: false } + }) + }) + + // Why: clients predating crossWorkspace reuse their own worktree id for the + // follow-up files.open, so sibling-workspace retargeting must be an explicit opt-in. + it('forwards crossWorkspace only when the client opts in', async () => { + const { runtime, dispatcher } = createDispatcher() + + await dispatcher.dispatch( + makeRequest('files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: '/sibling/docs/readme.md', + crossWorkspace: true + }) + ) + + expect(runtime.resolveTerminalPath).toHaveBeenLastCalledWith( + 'id:wt-1', + '/sibling/docs/readme.md', + null, + undefined, + null, + true + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 990689a741f..234bbb664cd 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -325,40 +325,6 @@ describe('file RPC methods', () => { }) }) - it('resolves a tapped terminal path for a selected worktree', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - resolveTerminalPath: vi.fn().mockResolvedValue({ - worktree: 'wt-1', - relativePath: 'src/index.ts', - exists: true, - isDirectory: false - }) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) - - const response = await dispatcher.dispatch( - makeRequest('files.resolveTerminalPath', { - worktree: 'id:wt-1', - pathText: '/repo/src/index.ts', - cwd: '/repo', - terminal: 'term-1' - }) - ) - - expect(runtime.resolveTerminalPath).toHaveBeenCalledWith( - 'id:wt-1', - '/repo/src/index.ts', - '/repo', - undefined, - 'term-1' - ) - expect(response).toMatchObject({ - ok: true, - result: { relativePath: 'src/index.ts', exists: true, isDirectory: false } - }) - }) - it('reads a terminal artifact through a grant-scoped method', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 06c2325913c..ffaa48b510e 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -74,6 +74,10 @@ const ResolveTerminalPath = WorktreeSelector.extend({ .unknown() .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) .nullable() + .optional(), + crossWorkspace: z + .unknown() + .transform((v) => v === true) .optional() }) @@ -252,7 +256,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [ params.pathText, params.cwd ?? null, clientId, - params.terminal ?? null + params.terminal ?? null, + params.crossWorkspace === true ) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 7e13e584c42..0fe273108a5 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -10,7 +10,9 @@ export const WorktreeSelector = z.object({ export const GitStatusParams = WorktreeSelector.extend({ includeIgnored: z.boolean().optional(), bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), - reuseLineStats: z.boolean().optional() + reuseLineStats: z.boolean().optional(), + // Shape is re-validated host-side before it reaches a git argv. + branchLineTotalMergeBase: z.string().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index c4ccdf071b6..c30e80f4338 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -93,6 +93,7 @@ export const GIT_METHODS: RpcMethod[] = [ params.includeIgnored === undefined && params.bypassEffectiveUpstreamNegativeCache === undefined && params.reuseLineStats === undefined && + params.branchLineTotalMergeBase === undefined && signal === undefined ? undefined : { @@ -103,6 +104,9 @@ export const GIT_METHODS: RpcMethod[] = [ ? { bypassEffectiveUpstreamNegativeCache: true } : {}), ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(params.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: params.branchLineTotalMergeBase }), ...(signal ? { signal } : {}) } return options === undefined diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index c035099d083..fc7bdc2adc3 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -4,6 +4,7 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' +import { TAB_ACTIVATION_INTENTS } from '../../../../shared/tab-activation-intent' import { OptionalBoolean } from '../schemas' export const WorktreeTabSelector = z.object({ @@ -24,7 +25,10 @@ export const ActivateTab = WorktreeTabSelector.extend({ .pipe(z.string().min(1, 'Missing tab id')), leafId: z.string().max(128).optional(), notifyClients: OptionalBoolean, - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + // Why: absent means user intent, so clients that predate this field keep the + // tab-open wake gesture. Only 'automatic' may be refused for a slept pane. + intent: z.enum(TAB_ACTIVATION_INTENTS).optional() }) export const CloseTab = ActivateTab.extend({ diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 428fde012a3..d31f8606709 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -68,6 +68,58 @@ describe('session tab RPC methods', () => { }) }) + // Why: only this field separates a reconnect probe from a user opening the tab, + // and the tab-open gesture is what wakes a deliberately slept pane (STA-3465). + it('forwards an automatic activation intent to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateMobileSessionTab: vi.fn().mockResolvedValue({ tabs: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.activate', { + worktree: 'id:wt-1', + tabId: 'tab-1', + notifyClients: false, + intent: 'automatic' + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith( + 'id:wt-1', + 'tab-1', + undefined, + expect.objectContaining({ intent: 'automatic' }) + ) + }) + + // Why: the field is additive, so a client that predates it sends nothing and + // must keep the permissive default rather than losing its wake gesture. + it('passes no intent for a client that omits the field', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateMobileSessionTab: vi.fn().mockResolvedValue({ tabs: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.activate', { + worktree: 'id:wt-1', + tabId: 'tab-1', + notifyClients: false + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.activateMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', undefined, { + notifyClients: false, + clientNavigationId: undefined, + navigation: 'caller' + }) + }) + it('refuses a reasonless close without invoking destructive runtime logic', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 7c4230c27b1..14716e0bf17 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -34,6 +34,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ runtime.activateMobileSessionTab(params.worktree, params.tabId, params.leafId, { notifyClients: params.notifyClients !== false, clientNavigationId: pairedDeviceId, + ...(params.intent ? { intent: params.intent } : {}), navigation: resolveRuntimeNavigationTarget({ navigation: params.navigation, notifyClients: params.notifyClients, diff --git a/src/main/runtime/rpc/methods/task-resume-state-schema.ts b/src/main/runtime/rpc/methods/task-resume-state-schema.ts index 825248ac4e7..f0a8e814e7c 100644 --- a/src/main/runtime/rpc/methods/task-resume-state-schema.ts +++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts @@ -9,7 +9,7 @@ export const TaskResumeState = z githubItemsPreset: z.string().nullable().optional(), githubItemsQuery: z.string().optional(), githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), - linearMode: z.enum(['issues', 'projects', 'views']).optional(), + linearMode: z.enum(['issues', 'projects', 'views', 'in-orca']).optional(), linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), linearQuery: z.string().optional(), linearContext: z diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index bd6cad49a32..05f94f2e631 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -71,6 +71,7 @@ import { sameTerminalOutputSourceIdentity, type TerminalOutputSourceRange } from '../../../../shared/terminal-output-source-range' +import type { TerminalSnapshotUnavailableReason } from '../../../../shared/terminal-snapshot-unavailability' import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer' import { withTerminalCloseAttribution } from '../terminal-close-attribution' @@ -93,6 +94,8 @@ type SnapshotFrameOptions = { cwd?: string | null truncated?: boolean truncatedByByteBudget?: boolean + // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. + unavailable?: TerminalSnapshotUnavailableReason source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] pendingEscapeTailAnsi?: string @@ -132,6 +135,7 @@ type TerminalMultiplexStream = { ackInFlightBytes: number ackWindowBytes: number supportsOutputPause: boolean + supportsWriteUnavailable: boolean outputPaused: boolean supportsDesktopViewportClaims: boolean desktopClaimTail: Promise @@ -312,6 +316,13 @@ function resolveMobileFloorClientId( return null } +type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' + +function isTerminalStreamInputRejection(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') +} + async function sendTerminalStreamInput( runtime: OrcaRuntimeService, args: { @@ -320,14 +331,14 @@ async function sendTerminalStreamInput( client: TerminalViewportClient | undefined isMobile: boolean } -): Promise { +): Promise { const action = { text: args.text, enter: false, interrupt: false } const clientId = args.isMobile ? args.client?.id : undefined const floorClaim: MobileInputFloorClaimHolder = { current: null } try { if (!clientId) { - await runtime.sendTerminal(args.terminal, action) - return + const result = await runtime.sendTerminal(args.terminal, action) + return result.accepted ? 'delivered' : 'rejected' } const result = await runtime.sendTerminal(args.terminal, action, { reserveWrite: (writePtyId) => { @@ -341,9 +352,12 @@ async function sendTerminalStreamInput( }) if (!result.accepted) { floorClaim.current?.rollback() + return 'rejected' } - } catch { + return 'delivered' + } catch (error) { floorClaim.current?.rollback() + return isTerminalStreamInputRejection(error) ? 'rejected' : 'failed' } } @@ -628,6 +642,7 @@ function sendSnapshotFrames( requestId: options.requestId, displayMode: options.displayMode, reason: options.reason, + unavailable: options.unavailable, seq: options.seq, cwd: options.cwd, source: options.source, @@ -1013,7 +1028,8 @@ const TerminalSubscribe = TerminalHandle.extend({ .object({ terminalBinaryStream: z.literal(1).optional(), desktopViewportClaims: z.literal(1).optional(), - mobileInputLeaseOnly: z.literal(1).optional() + mobileInputLeaseOnly: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() }) .optional() }) @@ -1034,7 +1050,8 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ ackOutput: z.literal(1).optional(), ackOutputSourceRanges: z.literal(1).optional(), desktopViewportClaims: z.literal(1).optional(), - outputPause: z.literal(1).optional() + outputPause: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() }) .optional() }) @@ -1671,6 +1688,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) emit({ type: 'error', streamId, message }) } + const notifyStreamWriteUnavailable = ( + stream: TerminalMultiplexStream, + outcome: TerminalStreamInputOutcome + ): void => { + if ( + closed || + streams.get(stream.streamId) !== stream || + outcome !== 'rejected' || + !stream.supportsWriteUnavailable + ) { + return + } + sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable) + } const sendResizedFrame = ( stream: TerminalMultiplexStream, event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } @@ -2123,16 +2154,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } // Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input. const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail - void inputClaimTail.then((claimed) => { + void inputClaimTail.then(async (claimed) => { if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { return } - return sendTerminalStreamInput(runtime, { + const outcome = await sendTerminalStreamInput(runtime, { terminal: stream.terminal, text, client: stream.client, isMobile: stream.isMobile }) + notifyStreamWriteUnavailable(stream, outcome) }) return } @@ -2279,6 +2311,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ displayMode, truncated: true, truncatedByByteBudget: false, + unavailable: 'pending-output-overflowed', data: '' }) return @@ -2298,6 +2331,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, truncatedByByteBudget: serialized?.truncatedByByteBudget, + // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. + unavailable: serialized ? undefined : 'no-serializable-buffer', data: serialized?.data ?? '' }) } catch (error) { @@ -2469,6 +2504,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ackInFlightBytes: 0, ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, supportsOutputPause: request.capabilities?.outputPause === 1, + supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, outputPaused: false, supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, desktopClaimTail: Promise.resolve(true), @@ -2885,6 +2921,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ : runtime.getRendererTerminalSerializerGeneration(ptyId) : 0 const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 + const supportsWriteUnavailable = params.capabilities?.writeUnavailable === 1 if (mobileInputLeaseOnly && clientId) { let closed = false let resolveStream = (): void => {} @@ -3132,12 +3169,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { return } - await sendTerminalStreamInput(runtime, { + const outcome = await sendTerminalStreamInput(runtime, { terminal: params.terminal, text, client: params.client, isMobile }) + if (!closed && outcome === 'rejected' && supportsWriteUnavailable) { + sendFrame(TerminalStreamOpcode.WriteUnavailable) + } }) return } diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts new file mode 100644 index 00000000000..e86978b2cf2 --- /dev/null +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' + +afterEach(() => { + vi.useRealTimers() +}) + +const INTERVAL_MS = 100 + +function makeSocket(): WebSocket { + return { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket +} + +describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { + it('keeps a client whose pong is merely delayed past one probe window', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) // probe #1 + heartbeat.noteAlive(socket) // pongs probe #1 + + // A transient blackhole: probe #2 goes out and its pong is stuck in the network. + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(socket.ping).toHaveBeenCalledTimes(2) + expect(socket.terminate).not.toHaveBeenCalled() + + // The path recovers and the delayed pong lands right after the next sweep would have run. + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + heartbeat.noteAlive(socket) + + // One unanswered probe is not evidence the peer is gone: it must still be connected, + // and it must have been re-probed rather than reaped. + expect(socket.terminate).not.toHaveBeenCalled() + expect(socket.ping).toHaveBeenCalledTimes(3) + heartbeat.stop() + }) + + it('reaps only after consecutive probes go unanswered, counted not timed', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const missesBeforeReap: number[] = [] + for (let probe = 0; probe < 6; probe += 1) { + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + missesBeforeReap.push( + (socket.terminate as unknown as ReturnType).mock.calls.length + ) + } + + // Silence must be tolerated for more than a single probe, and must eventually end the socket. + expect(missesBeforeReap[0]).toBe(0) + expect(missesBeforeReap[1]).toBe(0) + expect(missesBeforeReap.at(-1)).toBeGreaterThan(0) + heartbeat.stop() + }) + + it('lets any proof of life reset accumulated misses', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + // Two silent probes, then a single inbound frame, repeated well past any fixed budget. + for (let cycle = 0; cycle < 5; cycle += 1) { + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + heartbeat.noteAlive(socket) + } + + expect(socket.terminate).not.toHaveBeenCalled() + heartbeat.stop() + }) + + it('clears accumulated misses when the server event loop resumes from a pause', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const sweep = async (): Promise => { + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + const reaped = (candidate: WebSocket): boolean => + (candidate.terminate as unknown as ReturnType).mock.calls.length > 0 + + // Discover the budget rather than hardcoding it: the first sweep consumes the pong above, so + // every sweep after it is an unanswered probe. + await sweep() + let toleratedMisses = 0 + // Bounded so a heartbeat that never reaps fails here instead of hanging out to the test timeout. + while (!reaped(socket) && toleratedMisses < 20) { + await sweep() + toleratedMisses += 1 + } + expect(reaped(socket)).toBe(true) + expect(toleratedMisses).toBeGreaterThan(1) + heartbeat.stop() + + // Replay on a fresh socket: bank misses to one short of the limit, suspend, then bank the same + // number again. The pause must write the earlier misses off, not top them up to the limit. + const resumed = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + const resumedSocket = makeSocket() + resumed.noteAlive(resumedSocket) + resumed.start(() => [resumedSocket]) + resumed.noteAlive(resumedSocket) + await sweep() + for (let miss = 0; miss < toleratedMisses - 1; miss += 1) { + await sweep() + } + expect(reaped(resumedSocket)).toBe(false) + + now += 3_600_000 + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + for (let miss = 0; miss < toleratedMisses - 1; miss += 1) { + await sweep() + } + + expect(reaped(resumedSocket)).toBe(false) + resumed.stop() + }) +}) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts index a34b43c3c8b..e1a58d23587 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts @@ -7,12 +7,13 @@ afterEach(() => { }) describe('RemoteRuntimeServerHeartbeat', () => { - it('still reaps a client that misses a probe while another remains alive', async () => { + it('still reaps a persistently silent client while another remains alive', async () => { vi.useFakeTimers() let now = 1_000 const responsiveSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket const deadSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket - const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now) + // Limit of 2 keeps the sweep count readable; production uses the module default. + const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 2) heartbeat.noteAlive(responsiveSocket) heartbeat.noteAlive(deadSocket) // start() probes immediately: both are pinged now (probe #1) and cleared to await a pong. @@ -20,12 +21,20 @@ describe('RemoteRuntimeServerHeartbeat', () => { // Only the responsive socket pongs the immediate probe. heartbeat.noteAlive(responsiveSocket) + // Miss #1: not yet evidence, so the silent socket is re-probed rather than reaped. + now += 100 + await vi.advanceTimersByTimeAsync(100) + heartbeat.noteAlive(responsiveSocket) + expect(deadSocket.ping).toHaveBeenCalledTimes(2) + expect(deadSocket.terminate).not.toHaveBeenCalled() + + // Miss #2 reaches the limit: consecutive silence is evidence. now += 100 await vi.advanceTimersByTimeAsync(100) - expect(responsiveSocket.ping).toHaveBeenCalledTimes(2) + expect(responsiveSocket.ping).toHaveBeenCalledTimes(3) expect(responsiveSocket.terminate).not.toHaveBeenCalled() - expect(deadSocket.ping).toHaveBeenCalledTimes(1) + expect(deadSocket.ping).toHaveBeenCalledTimes(2) expect(deadSocket.terminate).toHaveBeenCalledTimes(1) heartbeat.stop() }) @@ -34,7 +43,8 @@ describe('RemoteRuntimeServerHeartbeat', () => { vi.useFakeTimers() let now = 1_000 const socket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket - const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now) + // Limit of 1 isolates the resume grant: without it the very next sweep would reap. + const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 1) heartbeat.noteAlive(socket) // start() probes immediately (ping #1); the socket pongs it. heartbeat.start(() => [socket]) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts index 3b5bb9760df..3913e9d9b6b 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts @@ -1,16 +1,27 @@ import type { WebSocket } from 'ws' +// Why: one unanswered probe is UNKNOWN, not death — a cellular/Tailscale blackhole or a stalled TCP +// retransmit routinely swallows a single pong from a peer that is still there (STA-3320). Only a run of +// consecutive unanswered probes is evidence. Three matches the web client's own 45s liveness budget +// (25s idle + 20s probe grace), so both ends of a paired session give up on roughly the same evidence. +const MISSED_PROBE_LIMIT = 3 + export class RemoteRuntimeServerHeartbeat { private timer: ReturnType | null = null private lastTickAt: number | null = null private readonly alive = new WeakSet() + // Why: consecutive probes a socket has left unanswered. Absent = zero, so live sockets cost nothing. + private readonly missedProbes = new WeakMap() constructor( private readonly intervalMs: number, private readonly now: () => number = Date.now, - private readonly warningClientCount = 128 + private readonly warningClientCount = 128, + private readonly missedProbeLimit = MISSED_PROBE_LIMIT ) {} + // Why: hot path — every inbound frame calls this. The sweep clears the miss count when it observes + // the flag, so proof of life stays a single WeakSet write. noteAlive(socket: WebSocket): void { this.alive.add(socket) } @@ -47,16 +58,27 @@ export class RemoteRuntimeServerHeartbeat { for (const socket of clients) { clientCount += 1 if (resumedFromPause) { - // Why: a delayed server tick cannot infer that clients missed a probe they had no chance to answer. + // Why: a delayed server tick cannot infer that clients missed a probe they had no chance to + // answer. Seeding alive also clears any misses banked before the pause, so a suspend can never + // top up a budget the client was never given a chance to defend. this.alive.add(socket) } - if (!this.alive.has(socket)) { - socket.terminate() - reaped += 1 - continue + if (this.alive.has(socket)) { + this.alive.delete(socket) + this.missedProbes.delete(socket) + } else { + const missed = (this.missedProbes.get(socket) ?? 0) + 1 + if (missed >= this.missedProbeLimit) { + this.missedProbes.delete(socket) + socket.terminate() + reaped += 1 + continue + } + this.missedProbes.set(socket, missed) } - this.alive.delete(socket) try { + // Why: re-probe every sweep, including missed ones, so a path that just recovered can prove + // itself on the next tick instead of waiting out the rest of the budget. socket.ping() } catch { // Why: a mid-teardown socket is finalized by its close/error listener. diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index b1584f0c604..dd46b36650e 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -22,6 +22,7 @@ import { import { SshPtyOutputIntake, type SshPtyOutputDataEvent } from '../../ipc/ssh-pty-output-intake' const SET_OUTPUT_PAUSED_OPCODE = 16 as TerminalStreamOpcode +const WRITE_UNAVAILABLE_OPCODE = 17 as TerminalStreamOpcode function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { const serializeAuthoritativeTerminalBuffer = @@ -131,7 +132,8 @@ function startDesktopMultiplexSubscribe( } function sendDesktopMultiplexSubscribe( - handlers: Map>) => void> + handlers: Map>) => void>, + capabilities: Record = { ackOutput: 1, desktopViewportClaims: 1 } ) { handlers.get(0)?.( decodeTerminalStreamFrame( @@ -143,7 +145,7 @@ function sendDesktopMultiplexSubscribe( streamId: 7, terminal: 'terminal-1', client: { id: 'desktop-1', type: 'desktop' }, - capabilities: { ackOutput: 1, desktopViewportClaims: 1 }, + capabilities, viewport: { cols: 120, rows: 40 } }) }) @@ -151,6 +153,135 @@ function sendDesktopMultiplexSubscribe( ) } +describe('terminal multiplex rejected input signalling', () => { + it('reports when locally accepted input never reaches the process', async () => { + const processWrites: string[] = [] + const sendTerminal = vi.fn().mockRejectedValue(new Error('terminal_not_writable')) + const harness = startDesktopMultiplexSubscribe({ + sendTerminal: sendTerminal as unknown as OrcaRuntimeService['sendTerminal'] + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + sendDesktopMultiplexSubscribe(harness.handlers, { + ackOutput: 1, + desktopViewportClaims: 1, + writeUnavailable: 1 + }) + await vi.waitFor(() => + expect( + harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed') + ).toBe(true) + ) + harness.binaryFrames.splice(0) + + const clientInputHandler = harness.handlers.get(7) + expect(clientInputHandler).toBeDefined() + clientInputHandler?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 7, + seq: 2, + payload: encodeTerminalStreamText('x') + }) + )! + ) + const clientReportedAccepted = true + + expect(clientReportedAccepted).toBe(true) + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledOnce()) + expect(processWrites).toEqual([]) + await vi.waitFor(() => + expect(harness.binaryFrames.some((frame) => frame[2] === WRITE_UNAVAILABLE_OPCODE)).toBe(true) + ) + }) + + it('does not send an unknown opcode to a legacy client', async () => { + const sendTerminal = vi.fn().mockRejectedValue(new Error('terminal_not_writable')) + const harness = startDesktopMultiplexSubscribe({ + sendTerminal: sendTerminal as unknown as OrcaRuntimeService['sendTerminal'] + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + sendDesktopMultiplexSubscribe(harness.handlers) + await vi.waitFor(() => + expect( + harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed') + ).toBe(true) + ) + harness.binaryFrames.splice(0) + + harness.handlers.get(7)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 7, + seq: 2, + payload: encodeTerminalStreamText('x') + }) + )! + ) + + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledOnce()) + expect(harness.binaryFrames.some((frame) => frame[2] === WRITE_UNAVAILABLE_OPCODE)).toBe(false) + }) + + it('does not report a late rejection to a replacement stream with the same id', async () => { + let settleWrite: (result: { + handle: string + accepted: boolean + bytesWritten: number + }) => void = () => {} + const hostWrite = new Promise<{ handle: string; accepted: boolean; bytesWritten: number }>( + (resolve) => { + settleWrite = resolve + } + ) + const sendTerminal = vi.fn(() => hostWrite) + const harness = startDesktopMultiplexSubscribe({ + sendTerminal: sendTerminal as unknown as OrcaRuntimeService['sendTerminal'] + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + const capabilities = { ackOutput: 1 as const, writeUnavailable: 1 as const } + sendDesktopMultiplexSubscribe(harness.handlers, capabilities) + await vi.waitFor(() => expect(harness.handlers.has(7)).toBe(true)) + harness.binaryFrames.splice(0) + + harness.handlers.get(7)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 7, + seq: 2, + payload: encodeTerminalStreamText('old') + }) + )! + ) + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledOnce()) + harness.handlers.get(7)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Unsubscribe, + streamId: 7, + seq: 3, + payload: new Uint8Array() + }) + )! + ) + sendDesktopMultiplexSubscribe(harness.handlers, capabilities) + await vi.waitFor(() => + expect( + harness.messages.filter((message) => JSON.parse(message).result?.type === 'subscribed') + ).toHaveLength(2) + ) + harness.binaryFrames.splice(0) + + settleWrite({ handle: 'terminal-1', accepted: false, bytesWritten: 0 }) + await hostWrite + await Promise.resolve() + + expect(harness.binaryFrames.some((frame) => frame[2] === WRITE_UNAVAILABLE_OPCODE)).toBe(false) + }) +}) + function sendDesktopSourceRangeSubscribe( handlers: Map>) => void> ) { @@ -3625,6 +3756,187 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('reports rejected input on a capable legacy binary stream', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const sendTerminal = vi.fn().mockRejectedValue(new Error('terminal_not_writable')) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => cleanups.get(id)?.()), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: sendTerminal as unknown as OrcaRuntimeService['sendTerminal'], + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1, writeUnavailable: 1 } + }), + (message) => messages.push(message), + { + connectionId: 'conn-subscribe-rejected-input', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + const streamId = JSON.parse( + messages.find((message) => JSON.parse(message).result?.type === 'subscribed')! + ).result.streamId as number + binaryFrames.splice(0) + handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 1, + payload: encodeTerminalStreamText('x') + }) + )! + ) + + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledOnce()) + await vi.waitFor(() => + expect(binaryFrames.some((frame) => frame[2] === WRITE_UNAVAILABLE_OPCODE)).toBe(true) + ) + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + }) + + it('never sends the rejection opcode to an un-negotiated legacy binary stream', async () => { + // The mobile client is exactly this subscriber: it declares + // terminalBinaryStream and nothing else, and its vendored opcode enum knows + // nothing past 12, so an unsolicited 17 is an unknown opcode on that wire. + // A capable desktop subscriber shares the runtime and is driven second, so + // its frame proves the rejection had already been processed for both. + const cleanups = new Map void>() + const sendTerminal = vi.fn().mockRejectedValue(new Error('terminal_not_writable')) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => cleanups.get(id)?.()), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: sendTerminal as unknown as OrcaRuntimeService['sendTerminal'], + updateDesktopViewport: vi.fn().mockResolvedValue(true), + handleMobileSubscribe: vi.fn(), + handleMobileUnsubscribe: vi.fn(), + updateMobileViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + async function subscribeLegacyBinary( + client: { id: string; type: 'mobile' | 'desktop' }, + capabilities: Record + ) { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { terminal: 'terminal-1', client, capabilities }), + (message) => messages.push(message), + { + connectionId: `conn-legacy-${client.id}`, + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + const streamId = JSON.parse( + messages.find((message) => JSON.parse(message).result?.type === 'subscribed')! + ).result.streamId as number + binaryFrames.splice(0) + return { + binaryFrames, + dispatchPromise, + sendInput: () => + handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 1, + payload: encodeTerminalStreamText('x') + }) + )! + ) + } + } + + const legacy = await subscribeLegacyBinary( + { id: 'mobile-1', type: 'mobile' }, + { terminalBinaryStream: 1 } + ) + const capable = await subscribeLegacyBinary( + { id: 'desktop-1', type: 'desktop' }, + { terminalBinaryStream: 1, writeUnavailable: 1 } + ) + + legacy.sendInput() + capable.sendInput() + + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => + expect(capable.binaryFrames.some((frame) => frame[2] === WRITE_UNAVAILABLE_OPCODE)).toBe(true) + ) + expect(legacy.binaryFrames.some((frame) => frame[2] === WRITE_UNAVAILABLE_OPCODE)).toBe(false) + + runtime.cleanupSubscription('terminal-1:mobile-1') + runtime.cleanupSubscription('terminal-1:desktop-1') + await Promise.all([legacy.dispatchPromise, capable.dispatchPromise]) + }) + it('owns and releases a viewport floor for legacy JSON desktop streams', async () => { const messages: string[] = [] const cleanups = new Map void>() diff --git a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts new file mode 100644 index 00000000000..c5f05cb0456 --- /dev/null +++ b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson +} from '../../../shared/terminal-stream-protocol' + +type SerializedBuffer = { data: string; cols: number; rows: number } | null +type SnapshotStartPayload = Record + +// Why: 256 KiB is the pending-output budget, so this many 1 KiB chunks always trips the overflow guard. +const OVERFLOW_CHUNKS = 400 + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +/** Drives one desktop multiplex stream up to a requested-snapshot reply and returns its SnapshotStart payload. */ +async function requestSnapshotReply(options: { + connectionId: string + /** Called for each requested-snapshot serialization attempt (attempt 1 is the initial subscribe snapshot). */ + serializeRequested: (attempt: number) => Promise + /** Runs while a requested-snapshot serialization is in flight, e.g. to flood pending output. */ + duringSerialize?: (attempt: number, pushOutput: (data: string) => void) => void +}): Promise<{ start: SnapshotStartPayload; chunks: string }> { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + let attempt = 0 + const serializeTerminalBuffer = vi.fn(async () => { + attempt += 1 + if (attempt === 1) { + return { data: 'initial', cols: 120, rows: 40 } + } + const requestedAttempt = attempt - 1 + options.duringSerialize?.(requestedAttempt, (data) => dataListenerRef.current?.(data)) + return options.serializeRequested(requestedAttempt) + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount: vi.fn().mockReturnValue(true), + updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true), + unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true), + unregisterRemoteDesktopViewers: vi.fn().mockResolvedValue(true), + isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false), + getRemoteDesktopFitHold: vi.fn().mockReturnValue({ mode: 'desktop-fit', cols: 120, rows: 40 }), + isRemoteDesktopViewerOwner: vi.fn().mockReturnValue(false), + getPtyOutputSequence: vi.fn().mockReturnValue(0), + serializeTerminalBuffer, + serializeAuthoritativeTerminalBuffer: serializeTerminalBuffer, + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => cleanups.get(id)?.()), + waitForTerminal: vi.fn(() => new Promise(() => {})), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + } as unknown as OrcaRuntimeService + + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: options.connectionId, + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 12, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + const framesBeforeRequest = binaryFrames.length + + handlers.get(12)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotRequest, + streamId: 12, + seq: 2, + payload: encodeTerminalStreamJson({ requestId: 77, scrollbackRows: 5000 }) + }) + )! + ) + + await vi.waitFor(() => + expect( + binaryFrames + .slice(framesBeforeRequest) + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd) + ).toBe(true) + ) + const replyFrames = binaryFrames + .slice(framesBeforeRequest) + .map((frame) => decodeTerminalStreamFrame(frame)) + const start = replyFrames.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)! + cleanups.get(`terminal-multiplex:${options.connectionId}`)?.() + await dispatchPromise + return { + start: decodeTerminalStreamJson(start.payload)!, + chunks: replyFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + } +} + +describe('requested terminal snapshot unavailability reasons', () => { + it('omits a reason when the host serialized a real buffer', async () => { + const { start, chunks } = await requestSnapshotReply({ + connectionId: 'conn-reason-success', + serializeRequested: async () => ({ data: 'restored output', cols: 120, rows: 40 }) + }) + expect(chunks).toBe('restored output') + expect(start).toMatchObject({ requestId: 77, truncated: false }) + expect(start.unavailable).toBeUndefined() + }) + + it('omits a reason for a proven-empty buffer so absence stays distinguishable from failure', async () => { + const { start, chunks } = await requestSnapshotReply({ + connectionId: 'conn-reason-empty', + serializeRequested: async () => ({ data: '', cols: 120, rows: 40 }) + }) + expect(chunks).toBe('') + expect(start).toMatchObject({ requestId: 77, truncated: false }) + expect(start.unavailable).toBeUndefined() + }) + + it('reports no-serializable-buffer when no serializer answered', async () => { + const { start, chunks } = await requestSnapshotReply({ + connectionId: 'conn-reason-null', + serializeRequested: async () => null + }) + expect(chunks).toBe('') + // Legacy fields stay exactly as an old client expects them. + expect(start).toMatchObject({ + requestId: 77, + truncated: false, + cols: 120, + rows: 40, + unavailable: 'no-serializable-buffer' + }) + }) + + it('reports pending-output-overflowed when the retry also overflowed', async () => { + const { start, chunks } = await requestSnapshotReply({ + connectionId: 'conn-reason-overflow', + serializeRequested: async () => ({ data: 'never delivered', cols: 120, rows: 40 }), + duringSerialize: (_attempt, pushOutput) => { + for (let index = 0; index < OVERFLOW_CHUNKS; index += 1) { + pushOutput(String(index).padStart(3, '0') + 'x'.repeat(1021)) + } + } + }) + expect(chunks).toBe('') + expect(start).toMatchObject({ + requestId: 77, + truncated: true, + truncatedByByteBudget: false, + unavailable: 'pending-output-overflowed' + }) + }) + + it('recovers without a reason when only the first attempt overflowed', async () => { + const { start, chunks } = await requestSnapshotReply({ + connectionId: 'conn-reason-overflow-once', + serializeRequested: async () => ({ data: 'retry snapshot', cols: 120, rows: 40 }), + duringSerialize: (attempt, pushOutput) => { + if (attempt > 1) { + return + } + for (let index = 0; index < OVERFLOW_CHUNKS; index += 1) { + pushOutput(String(index).padStart(3, '0') + 'x'.repeat(1021)) + } + } + }) + expect(chunks).toBe('retry snapshot') + expect(start).toMatchObject({ requestId: 77, truncated: false }) + expect(start.unavailable).toBeUndefined() + }) +}) diff --git a/src/main/runtime/rpc/ws-transport-accept-order.test.ts b/src/main/runtime/rpc/ws-transport-accept-order.test.ts index 4f56a2da53e..20ae28f0f0e 100644 --- a/src/main/runtime/rpc/ws-transport-accept-order.test.ts +++ b/src/main/runtime/rpc/ws-transport-accept-order.test.ts @@ -83,7 +83,7 @@ describe('WebSocketTransport accepted socket ordering', () => { ).toEqual([0, 0, 0, 0]) }) - it('reaps an unresponsive accepted socket by the first interval deadline', async () => { + it('reaps an unresponsive accepted socket once consecutive probes go unanswered', async () => { vi.useFakeTimers() const events: string[] = [] let socket: WebSocket @@ -99,19 +99,22 @@ describe('WebSocketTransport accepted socket ordering', () => { socket.once('open', () => events.push('open')) socket.emit('open') lifecycle.handleConnection(socket) - await vi.advanceTimersByTimeAsync(99) + // A single silent interval is not evidence: the socket is re-probed, not reaped. + await vi.advanceTimersByTimeAsync(100) + expect(socket.terminate).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(199) expect(socket.terminate).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) expect(socket.terminate).toHaveBeenCalledTimes(1) - expect(events).toEqual(['open', 'ping', 'close']) + expect(events).toEqual(['open', 'ping', 'ping', 'ping', 'close']) expect(lifecycle.heartbeatConnections.size).toBe(0) expect(lifecycle.heartbeat.timer).toBeNull() expect(vi.getTimerCount()).toBe(0) }) - it('keeps later sockets on the shared cadence and reaps them within two intervals', async () => { + it('keeps later sockets on the shared cadence and reaps them on the shared budget', async () => { vi.useFakeTimers() vi.setSystemTime(0) const firstPingTimes: number[] = [] @@ -150,13 +153,15 @@ describe('WebSocketTransport accepted socket ordering', () => { expect(laterPingTimes).toEqual([100]) expect(laterSocket.terminate).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(99) + // Re-probed on every sweep while its misses bank, so a recovered path can answer immediately. + await vi.advanceTimersByTimeAsync(299) + expect(laterPingTimes).toEqual([100, 200, 300]) expect(laterSocket.terminate).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) expect(laterSocket.terminate).toHaveBeenCalledTimes(1) - expect(laterReapTimes).toEqual([200]) - expect(firstPingTimes).toEqual([0, 100, 200]) + expect(laterReapTimes).toEqual([400]) + expect(firstPingTimes).toEqual([0, 100, 200, 300, 400]) expect( ['pong', 'message', 'close', 'error'].map((event) => laterSocket.listenerCount(event)) ).toEqual([0, 0, 0, 0]) diff --git a/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts b/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts new file mode 100644 index 00000000000..a8a5eb9c4de --- /dev/null +++ b/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts @@ -0,0 +1,83 @@ +// Real ws server + real ws client over loopback: a live, well-behaved peer whose pong is swallowed by +// a transient blackhole must not be terminated (STA-3320). +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { WebSocketTransport } from './ws-transport' + +const PROBE_INTERVAL_MS = 150 + +// Why: the heartbeat calls now() exactly once per sweep, so a per-call step makes elapsed exactly one +// interval every sweep — real scheduler jitter can never be mistaken for a suspended loop, and no +// assertion in this file depends on wall time. +function createStepClock(stepMs: number): () => number { + let calls = 0 + return () => 1_000_000 + calls++ * stepMs +} + +describe('WebSocketTransport under transient packet loss', () => { + const transports: WebSocketTransport[] = [] + const sockets: WebSocket[] = [] + + afterEach(async () => { + for (const socket of sockets) { + socket.removeAllListeners() + socket.terminate() + } + sockets.length = 0 + await Promise.all(transports.map((t) => t.stop().catch(() => {}))) + transports.length = 0 + }) + + async function startTransport(): Promise { + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: 0, + heartbeatIntervalMs: PROBE_INTERVAL_MS, + heartbeatNow: createStepClock(PROBE_INTERVAL_MS), + // Why: this test is about liveness, not the auth handshake; keep the pre-auth reaper out of it. + preAuthTimeoutMs: 600_000 + }) + transports.push(transport) + await transport.start() + return transport + } + + it('keeps a live client whose pong is swallowed for a single probe', async () => { + const transport = await startTransport() + // autoPong:false hands us the protocol-level pong so we can drop exactly one, the way a + // cellular/Tailscale blackhole drops exactly the frames in flight during the stall. + const client = new WebSocket(`ws://127.0.0.1:${transport.resolvedPort}`, { autoPong: false }) + sockets.push(client) + + let probesReceived = 0 + const pongedProbes: number[] = [] + const swallowedProbe = 2 + let closed = false + client.on('ping', () => { + probesReceived += 1 + if (probesReceived === swallowedProbe) { + return + } + pongedProbes.push(probesReceived) + client.pong() + }) + client.on('close', () => { + closed = true + }) + await new Promise((resolve, reject) => { + client.once('open', resolve) + client.once('error', reject) + }) + + // Bounded by counted probe events, never by elapsed time. + await vi.waitFor(() => expect(closed || probesReceived >= swallowedProbe + 2).toBe(true), { + timeout: 15_000, + interval: 10 + }) + + expect({ closed, probesReceived }).toEqual({ closed: false, probesReceived: 4 }) + // The peer was demonstrably alive across the blackhole: it answered the probes on both sides of it. + expect(pongedProbes).toEqual([1, 3, 4]) + expect(client.readyState).toBe(WebSocket.OPEN) + }) +}) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 1444813fd65..ec58f8697ae 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -21,7 +21,7 @@ type WebSocketMessageHandler = { ): void }['bivarianceHack'] -// Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~30s (clients auto-pong per RFC 6455). +// Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~60s (clients auto-pong per RFC 6455), since a reap needs consecutive unanswered probes rather than one (STA-3320). const HEARTBEAT_INTERVAL_MS = 15_000 export type WebSocketTransportOptions = { diff --git a/src/main/source-control/pull-request-linked-issue.test.ts b/src/main/source-control/pull-request-linked-issue.test.ts new file mode 100644 index 00000000000..11ad0b63328 --- /dev/null +++ b/src/main/source-control/pull-request-linked-issue.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { loadPullRequestLinkedIssue } from './pull-request-linked-issue' + +const mocks = vi.hoisted(() => ({ + getGitHubIssue: vi.fn(), + getGitLabIssue: vi.fn() +})) + +vi.mock('../github/issues', () => ({ getIssue: mocks.getGitHubIssue })) +vi.mock('../gitlab/issues', () => ({ getIssue: mocks.getGitLabIssue })) + +describe('loadPullRequestLinkedIssue', () => { + beforeEach(() => { + mocks.getGitHubIssue.mockReset() + mocks.getGitLabIssue.mockReset() + }) + + it('loads a GitHub issue title and description', async () => { + mocks.getGitHubIssue.mockResolvedValue({ + number: 12, + title: 'Stop phantom polling', + description: 'Do not stat Linux-only paths on macOS.' + }) + + await expect( + loadPullRequestLinkedIssue({ + meta: { linkedIssue: 12 }, + provider: 'github', + repoPath: '/repo' + }) + ).resolves.toEqual({ + provider: 'github', + number: 12, + title: 'Stop phantom polling', + description: 'Do not stat Linux-only paths on macOS.' + }) + }) + + it('loads GitLab details without falling back to the GitHub issue', async () => { + mocks.getGitLabIssue.mockResolvedValue({ + number: 34, + title: 'Fix runner polling', + description: 'The runner checks paths that cannot exist.' + }) + + await expect( + loadPullRequestLinkedIssue({ + meta: { linkedIssue: 12, linkedGitLabIssue: 34 }, + provider: 'gitlab', + repoPath: '/repo', + connectionId: 'ssh-1' + }) + ).resolves.toMatchObject({ provider: 'gitlab', number: 34, title: 'Fix runner polling' }) + expect(mocks.getGitHubIssue).not.toHaveBeenCalled() + }) + + it('uses persisted work-item title when the provider lookup fails', async () => { + mocks.getGitHubIssue.mockResolvedValue(null) + + await expect( + loadPullRequestLinkedIssue({ + meta: { + linkedIssue: 12, + linkedWorkItem: { + provider: 'github', + type: 'issue', + number: 12, + title: 'Cached title', + url: 'https://github.com/acme/repo/issues/12' + } + }, + provider: 'github', + repoPath: '/repo' + }) + ).resolves.toMatchObject({ title: 'Cached title', description: '' }) + }) + + it('does not attach another provider issue to a Bitbucket PR', async () => { + await expect( + loadPullRequestLinkedIssue({ + meta: { linkedIssue: 12, linkedGitLabIssue: 34 }, + provider: 'bitbucket', + repoPath: '/repo' + }) + ).resolves.toBeNull() + expect(mocks.getGitHubIssue).not.toHaveBeenCalled() + expect(mocks.getGitLabIssue).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/source-control/pull-request-linked-issue.ts b/src/main/source-control/pull-request-linked-issue.ts new file mode 100644 index 00000000000..2e73cd30d8d --- /dev/null +++ b/src/main/source-control/pull-request-linked-issue.ts @@ -0,0 +1,79 @@ +import type { HostedReviewProvider } from '../../shared/hosted-review' +import type { PullRequestLinkedIssue } from '../../shared/pull-request-generation' +import { isLinkedIssueNumber } from '../../shared/source-control-ai-action-variables' +import type { WorkspaceLinkedItem } from '../../shared/types' +import { getIssue as getGitHubIssue } from '../github/issues' +import { getIssue as getGitLabIssue } from '../gitlab/issues' + +export type PullRequestLinkedIssueMeta = { + linkedIssue?: number | null + linkedGitLabIssue?: number | null + linkedWorkItem?: WorkspaceLinkedItem | null +} + +type LocalGitOptions = { wslDistro?: string } + +function inferIssueProvider( + meta: PullRequestLinkedIssueMeta, + provider?: HostedReviewProvider | null +): 'github' | 'gitlab' | null { + if (provider === 'github' || provider === 'gitlab') { + return provider + } + if (provider) { + return null + } + if (meta.linkedWorkItem?.type === 'issue') { + if (meta.linkedWorkItem.provider === 'github' || meta.linkedWorkItem.provider === 'gitlab') { + return meta.linkedWorkItem.provider + } + } + const hasGitHub = isLinkedIssueNumber(meta.linkedIssue) + const hasGitLab = isLinkedIssueNumber(meta.linkedGitLabIssue) + return hasGitHub === hasGitLab ? null : hasGitHub ? 'github' : 'gitlab' +} + +function fallbackTitle( + meta: PullRequestLinkedIssueMeta, + provider: 'github' | 'gitlab', + number: number +): string { + const item = meta.linkedWorkItem + return item?.provider === provider && item.type === 'issue' && item.number === number + ? item.title + : '(title unavailable)' +} + +export async function loadPullRequestLinkedIssue(args: { + meta: PullRequestLinkedIssueMeta | null | undefined + provider?: HostedReviewProvider | null + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitOptions +}): Promise { + if (!args.meta) { + return null + } + const provider = inferIssueProvider(args.meta, args.provider) + const number = + provider === 'github' + ? args.meta.linkedIssue + : provider === 'gitlab' + ? args.meta.linkedGitLabIssue + : null + if (!provider || !isLinkedIssueNumber(number)) { + return null + } + + const issue = + provider === 'github' + ? await getGitHubIssue(args.repoPath, number, args.connectionId, args.localGitOptions) + : await getGitLabIssue(args.repoPath, number, args.connectionId, args.localGitOptions) + + return { + provider, + number, + title: issue?.title || fallbackTitle(args.meta, provider, number), + description: issue?.description ?? '' + } +} diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index f58e4172787..eeca0ce5108 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -502,6 +502,50 @@ describe('SshChannelMultiplexer', () => { await expect(mux.request('pty.spawn')).rejects.toThrow('Multiplexer disposed') }) + it('tags a request after a shutdown dispose with DISPOSED', async () => { + mux.dispose() + + const error = (await mux.request('pty.spawn').catch((e: unknown) => e)) as Error & { + code?: string + } + expect(error.code).toBe('DISPOSED') + }) + + it('reports a request after a lost connection as transient', async () => { + transport.closeCallbacks[0]() + + const error = (await mux + .request('fs.readDir', { path: '/home/me' }) + .catch((e: unknown) => e)) as Error & { code?: string } + expect(error.message).toBe('SSH connection lost, reconnecting...') + expect(error.code).toBe('CONNECTION_LOST') + }) + + it('reports a settled notify after a lost connection as transient', () => { + transport.closeCallbacks[0]() + + const settled = vi.fn() + mux.notifyWithSettlement('pty.data', { id: 'pty-1', data: 'x' }, settled) + + expect(settled).toHaveBeenCalledWith({ + ok: false, + error: expect.objectContaining({ + message: 'SSH connection lost, reconnecting...', + code: 'CONNECTION_LOST' + }) + }) + }) + + it('fires a dispose handler registered after dispose with the recorded reason', () => { + mux.dispose('connection_lost') + + const disposeHandler = vi.fn() + mux.onDispose(disposeHandler) + + expect(disposeHandler).toHaveBeenCalledWith('connection_lost') + expect(disposeHandler).toHaveBeenCalledTimes(1) + }) + it('ignores notify after dispose', () => { mux.dispose() mux.notify('pty.data', { id: 'pty-1', data: 'x' }) diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 206d008c151..0000f2f353b 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -41,6 +41,21 @@ export type NotificationHandler = (method: string, params: Record) => void export type RequestHandler = (params: Record) => Promise | unknown +export type MultiplexerDisposeReason = 'shutdown' | 'connection_lost' + +// Why: the renderer uses the message/code to distinguish temporary disconnects +// (show reconnection overlay) from permanent shutdown (show error toast), so +// every producer of a disposal rejection must mint it here — a divergent copy +// silently downgrades the relay-lost UI to a bug-report toast. +export function createSshDisposalError(reason: MultiplexerDisposeReason): Error & { code: string } { + const lost = reason === 'connection_lost' + const err = new Error( + lost ? 'SSH connection lost, reconnecting...' : 'Multiplexer disposed' + ) as Error & { code: string } + err.code = lost ? 'CONNECTION_LOST' : 'DISPOSED' + return err +} + const REQUEST_TIMEOUT_MS = 30_000 const MAX_ORDINARY_UNACKED_TIMESTAMPS = 4095 const MAX_UNACKED_TIMESTAMPS = MAX_ORDINARY_UNACKED_TIMESTAMPS + 1 @@ -85,6 +100,7 @@ export class SshChannelMultiplexer { private disposeHandlers: ((reason: 'shutdown' | 'connection_lost') => void)[] = [] private connectionHealthTimer: ReturnType | null = null private disposed = false + private disposeReason: 'shutdown' | 'connection_lost' | null = null private decoderReadPaused = false private writerSaturated = false @@ -181,6 +197,12 @@ export class SshChannelMultiplexer { // never fires the reconnect logic. onDispose(handler: (reason: 'shutdown' | 'connection_lost') => void): () => void { if (this.disposed) { + // Why: a late subscriber must still learn the channel died; retaining it would leak the closure (#11953). + try { + handler(this.disposeReason ?? 'shutdown') + } catch { + // Don't let a handler error escape into the subscriber's registration path + } return () => {} } this.disposeHandlers.push(handler) @@ -201,7 +223,7 @@ export class SshChannelMultiplexer { options?: SshMultiplexerRequestOptions ): Promise { if (this.disposed) { - throw new Error('Multiplexer disposed') + throw this.disposedError() } if (options?.signal?.aborted) { const error = new Error(`Request "${method}" was cancelled`) as Error & { name: string } @@ -289,7 +311,7 @@ export class SshChannelMultiplexer { onSettled: (result: { ok: true } | { ok: false; error: Error }) => void ): void { if (this.disposed) { - onSettled({ ok: false, error: new Error('Multiplexer disposed') }) + onSettled({ ok: false, error: this.disposedError() }) return } this.sendMessage( @@ -338,33 +360,24 @@ export class SshChannelMultiplexer { ) } this.disposed = true + this.disposeReason = reason if (this.connectionHealthTimer) { clearInterval(this.connectionHealthTimer) this.connectionHealthTimer = null } - // Why: the renderer uses the error code to distinguish temporary disconnects - // (show reconnection overlay) from permanent shutdown (show error toast). - const errorMessage = - reason === 'connection_lost' ? 'SSH connection lost, reconnecting...' : 'Multiplexer disposed' - const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' - for (const waiter of this.livenessProbeWaiters.splice(0)) { waiter.fail() } for (const [id, pending] of this.pendingRequests) { pending.cleanup() - const err = new Error(errorMessage) as Error & { code: string } - err.code = errorCode - pending.reject(err) + pending.reject(this.disposedError()) this.pendingRequests.delete(id) } - const writerError = new Error(errorMessage) as Error & { code: string } - writerError.code = errorCode - this.writer.dispose(writerError) + this.writer.dispose(this.disposedError()) this.unackedTimestamps.clear() // Why: relay teardown can race with late provider registration; disposed // muxes must not retain provider/session closures through subscribers. @@ -389,6 +402,10 @@ export class SshChannelMultiplexer { // ── Private ─────────────────────────────────────────────────────── + private disposedError(): Error & { code: string } { + return createSshDisposalError(this.disposeReason ?? 'shutdown') + } + private sendMessage( msg: JsonRpcMessage, onSettled?: (result: MultiplexerWriteSettlement) => void diff --git a/src/main/ssh/ssh-file-stream-inactivity-deadline.ts b/src/main/ssh/ssh-file-stream-inactivity-deadline.ts new file mode 100644 index 00000000000..5470e8121fe --- /dev/null +++ b/src/main/ssh/ssh-file-stream-inactivity-deadline.ts @@ -0,0 +1,55 @@ +import { + subscribeSystemPowerLifecycle, + type SystemPowerLifecycleListener +} from '../system-power-lifecycle' + +export const SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS = 60_000 + +type SystemPowerLifecycleSubscription = (listener: SystemPowerLifecycleListener) => () => void + +export function createSshFileStreamInactivityDeadline( + onTimeout: () => void, + subscribePowerLifecycle: SystemPowerLifecycleSubscription = subscribeSystemPowerLifecycle +): { + reset: () => void + clear: () => void +} { + let timer: ReturnType | null = null + let unsubscribePowerLifecycle: (() => void) | null = null + let suspended = false + const clearTimer = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + const arm = (): void => { + clearTimer() + if (suspended) { + return + } + timer = setTimeout(onTimeout, SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS) + timer.unref?.() + } + const reset = (): void => { + if (!unsubscribePowerLifecycle) { + unsubscribePowerLifecycle = subscribePowerLifecycle({ + onSuspend: () => { + suspended = true + clearTimer() + }, + onResume: () => { + suspended = false + arm() + } + }) + } + arm() + } + const clear = (): void => { + clearTimer() + unsubscribePowerLifecycle?.() + unsubscribePowerLifecycle = null + } + return { reset, clear } +} diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index 2147ed0ef4f..f61939d6c2f 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -1,6 +1,10 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { STREAM_CHUNK_SIZE, JsonRpcErrorCode, RelayErrorCode } from './relay-protocol' import type { FileReadResult } from '../providers/types' +import { + createSshFileStreamInactivityDeadline, + SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS +} from './ssh-file-stream-inactivity-deadline' const RESULT_ENCODING_BASE64 = 'base64' const SENTINEL_STREAM_ID = -1 @@ -76,6 +80,14 @@ export async function readFileViaStream( const pending: PendingFrame[] = [] let metadataReady = false + const inactivity = createSshFileStreamInactivityDeadline(() => { + fail( + new StreamProtocolError( + `File stream stalled (>${SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS}ms without data)` + ) + ) + }) + const cancel = (): void => { if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { try { @@ -91,6 +103,7 @@ export async function readFileViaStream( return } settled = true + inactivity.clear() cancel() cleanup() reject(err) @@ -101,6 +114,7 @@ export async function readFileViaStream( return } settled = true + inactivity.clear() cleanup() resolve(value) } @@ -148,6 +162,7 @@ export async function readFileViaStream( expectedSeq += 1 receivedChunks += 1 bytesReceived += decoded.length + inactivity.reset() // Why: credit-based flow control — the relay caps unacked chunks so bulk // stream frames cannot queue unbounded ahead of interactive pty.data // frames on the shared SSH channel. Old relays ignore this notification. @@ -314,6 +329,7 @@ export async function readFileViaStream( } streamIdRef.current = metadata.streamId metadataReady = true + inactivity.reset() drainPending() }) .catch((err) => { diff --git a/src/main/ssh/ssh-git-response-stream-reader.test.ts b/src/main/ssh/ssh-git-response-stream-reader.test.ts new file mode 100644 index 00000000000..8b08701a10d --- /dev/null +++ b/src/main/ssh/ssh-git-response-stream-reader.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' +import { requestGitStreamable } from './ssh-git-response-stream-reader' +import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' + +function createMockTransport(): MultiplexerTransport { + return { + write: () => {}, + onData: () => {}, + onClose: () => {} + } +} + +describe('requestGitStreamable on an already-dead multiplexer', () => { + it('rejects as a transient relay loss and leaves no listener on the caller signal', async () => { + const mux = new SshChannelMultiplexer(createMockTransport()) + mux.dispose('connection_lost') + const controller = new AbortController() + const addListener = vi.spyOn(controller.signal, 'addEventListener') + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + + await expect( + requestGitStreamable(mux, 'git.status', { cwd: '/repo' }, { signal: controller.signal }) + ).rejects.toThrow('SSH connection lost, reconnecting...') + + // #11953: a disposed mux fails synchronously inside onDispose, so the abort + // listener must already be registered when that cleanup runs — otherwise it + // outlives the request for the lifetime of the caller's signal. + expect(removeListener).toHaveBeenCalledTimes(addListener.mock.calls.length) + }) +}) diff --git a/src/main/ssh/ssh-git-response-stream-reader.ts b/src/main/ssh/ssh-git-response-stream-reader.ts index ef63c7a6dc0..6a50dc28a72 100644 --- a/src/main/ssh/ssh-git-response-stream-reader.ts +++ b/src/main/ssh/ssh-git-response-stream-reader.ts @@ -1,4 +1,5 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { createSshDisposalError } from './ssh-channel-multiplexer' import { RelayErrorCode, isGitResponseStreamMarker } from './relay-protocol' const SENTINEL_STREAM_ID = -1 @@ -245,18 +246,6 @@ export function requestGitStreamable( handleStreamError(p) }) ) - unsubscribers.push( - mux.onDispose((reason) => { - const err = new Error( - reason === 'connection_lost' - ? 'SSH connection lost, reconnecting...' - : 'Multiplexer disposed' - ) as Error & { code: string } - err.code = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' - fail(err) - }) - ) - if (options?.signal) { const signal = options.signal if (signal.aborted) { @@ -274,6 +263,10 @@ export function requestGitStreamable( unsubscribers.push(() => signal.removeEventListener('abort', onAbort)) } + // Why: registered last because an already-disposed mux fails synchronously here, + // and that cleanup must be able to drop the abort listener above (#11953). + unsubscribers.push(mux.onDispose((reason) => fail(createSshDisposalError(reason)))) + // Why: forward only the mux-request options (signal/timeoutMs) and omit them // entirely when absent, so callers that previously issued a 2-arg // mux.request keep the same call shape (and their tests). inactivityTimeoutMs diff --git a/src/main/ssh/ssh-owner-admission-blocked-error.ts b/src/main/ssh/ssh-owner-admission-blocked-error.ts new file mode 100644 index 00000000000..35055cb4880 --- /dev/null +++ b/src/main/ssh/ssh-owner-admission-blocked-error.ts @@ -0,0 +1,21 @@ +// Why a typed error instead of raw transport classification: another authenticated connection holds +// the PTY session owner claim, and no relay redeploy or backoff attempt can reconcile that. The +// reconnect ladder has to stop and say what actually happened rather than blame the link. + +export class SshOwnerAdmissionBlockedError extends Error { + readonly name = 'SshOwnerAdmissionBlockedError' + + constructor(targetId: string, options?: { cause?: unknown }) { + super( + `Another connection currently owns the remote terminals for ${targetId}. ` + + `Disconnect that client, or wait for it to release ownership, before reconnecting.`, + options + ) + } +} + +export function isSshOwnerAdmissionBlockedError( + err: unknown +): err is SshOwnerAdmissionBlockedError { + return err instanceof SshOwnerAdmissionBlockedError +} diff --git a/src/main/ssh/ssh-owner-recovery-retry.test.ts b/src/main/ssh/ssh-owner-recovery-retry.test.ts index 9c8f4bd6a14..68e835abc41 100644 --- a/src/main/ssh/ssh-owner-recovery-retry.test.ts +++ b/src/main/ssh/ssh-owner-recovery-retry.test.ts @@ -1,9 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR, + PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR, + PTY_CONSUMER_OWNER_HELD_SELF_ERROR, PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR } from '../../shared/pty-consumer-session' -import { retrySshOwnerRecoveryWhileBlocked } from './ssh-owner-recovery-retry' +import { + isSshOwnerAdmissionBlocked, + retrySshOwnerRecoveryWhileBlocked, + SSH_OWNER_HELD_DISCONNECTED_WAIT_MS, + SSH_OWNER_HELD_SELF_WAIT_MS +} from './ssh-owner-recovery-retry' function publicationPendingError(): Error & { code: number } { return Object.assign(new Error('Owner grant publication is still pending'), { @@ -17,6 +25,10 @@ function supersededError(): Error & { code: number } { }) } +function heldError(code: number): Error & { code: number } { + return Object.assign(new Error('PTY session owner is held'), { code }) +} + function openGate() { return { isCurrent: () => true, @@ -79,6 +91,137 @@ describe('SSH owner recovery retry', () => { expect(attempt).toHaveBeenCalledTimes(2) }) + it('retries a disconnected holder until it releases admission', async () => { + vi.useFakeTimers() + const attempt = vi + .fn<() => Promise>() + .mockRejectedValueOnce(heldError(PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR)) + .mockRejectedValueOnce(heldError(PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR)) + .mockResolvedValue('recovered') + + const recovery = retrySshOwnerRecoveryWhileBlocked(attempt, openGate()) + await vi.advanceTimersByTimeAsync(75) + + await expect(recovery).resolves.toBe('recovered') + expect(attempt).toHaveBeenCalledTimes(3) + }) + + it('never retries an attached holder', async () => { + const error = heldError(PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR) + const attempt = vi.fn<() => Promise>().mockRejectedValue(error) + + // Why blocked, not transient: another connection is live on the claim, so no amount of waiting + // inside this admission changes the answer. + await expect(retrySshOwnerRecoveryWhileBlocked(attempt, openGate())).rejects.toBe(error) + expect(attempt).toHaveBeenCalledOnce() + expect(isSshOwnerAdmissionBlocked(error)).toBe(true) + expect(isSshOwnerAdmissionBlocked(publicationPendingError())).toBe(false) + }) + + it('gives a disconnected holder its own budget rather than the publication one', async () => { + vi.useFakeTimers() + let failures = 0 + const attempt = vi.fn<() => Promise>().mockImplementation(async () => { + if (failures++ < 6) { + throw heldError(PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR) + } + return 'recovered' + }) + + // Why a 60ms publication budget: six backoff waits run well past it, so a single shared deadline + // would give up before the incumbent's clamped grace floor could ever elapse. + const recovery = retrySshOwnerRecoveryWhileBlocked(attempt, openGate(), 60) + await vi.advanceTimersByTimeAsync(SSH_OWNER_HELD_DISCONNECTED_WAIT_MS) + + await expect(recovery).resolves.toBe('recovered') + expect(attempt).toHaveBeenCalledTimes(7) + }) + + it('reports each exhausted retry budget under its own reason', async () => { + vi.useFakeTimers() + const exhausted: string[] = [] + const gate = { ...openGate(), onRetryExhausted: (reason: string) => exhausted.push(reason) } + + const pending = retrySshOwnerRecoveryWhileBlocked( + vi.fn<() => Promise>().mockRejectedValue(publicationPendingError()), + gate, + 60 + ) + const pendingRejection = expect(pending).rejects.toThrow('publication') + await vi.advanceTimersByTimeAsync(60) + await pendingRejection + + const held = retrySshOwnerRecoveryWhileBlocked( + vi + .fn<() => Promise>() + .mockRejectedValue(heldError(PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR)), + gate, + 60 + ) + const heldRejection = expect(held).rejects.toThrow('held') + // Why the disconnected budget and not the 60ms one: each reason carries its own deadline so a + // settling publication cannot spend the budget that waits out a grace floor. + await vi.advanceTimersByTimeAsync(SSH_OWNER_HELD_DISCONNECTED_WAIT_MS) + await heldRejection + + expect(exhausted).toEqual(['publication-pending', 'disconnected-holder']) + }) + + it('starts each budget when its own phase begins', async () => { + vi.useFakeTimers() + const start = Date.now() + let disconnectedAttempts = 0 + const attempt = vi.fn<() => Promise>().mockImplementation(async () => { + // A publication that takes longer to settle than the whole disconnected budget. + if (Date.now() - start < SSH_OWNER_HELD_DISCONNECTED_WAIT_MS + 100) { + throw publicationPendingError() + } + return disconnectedAttempts++ < 3 + ? Promise.reject(heldError(PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR)) + : 'recovered' + }) + + const recovery = retrySshOwnerRecoveryWhileBlocked(attempt, openGate(), 30_000) + await vi.advanceTimersByTimeAsync(30_000) + + // Why this fails on eagerly computed deadlines: the disconnected budget would have started at + // entry and be long gone by the time the first -32045 arrives, giving that phase zero attempts. + await expect(recovery).resolves.toBe('recovered') + expect(disconnectedAttempts).toBeGreaterThan(1) + }) + + it("treats the client's own attached connection as transient, not blocked", async () => { + vi.useFakeTimers() + const selfError = heldError(PTY_CONSUMER_OWNER_HELD_SELF_ERROR) + const attempt = vi + .fn<() => Promise>() + .mockRejectedValueOnce(selfError) + .mockResolvedValue('recovered') + + const recovery = retrySshOwnerRecoveryWhileBlocked(attempt, openGate()) + await vi.advanceTimersByTimeAsync(25) + + await expect(recovery).resolves.toBe('recovered') + // Why not blocked: in a one-app deployment the incumbent is this client's own zombie, so parking + // the target in 'error' with no retry strands the user until they restart the app. + expect(isSshOwnerAdmissionBlocked(selfError)).toBe(false) + }) + + it('lets an exhausted self-holder fall through to ordinary reconnect backoff', async () => { + vi.useFakeTimers() + const selfError = heldError(PTY_CONSUMER_OWNER_HELD_SELF_ERROR) + const attempt = vi.fn<() => Promise>().mockRejectedValue(selfError) + + const recovery = retrySshOwnerRecoveryWhileBlocked(attempt, openGate()) + const rejection = expect(recovery).rejects.toBe(selfError) + await vi.advanceTimersByTimeAsync(SSH_OWNER_HELD_SELF_WAIT_MS) + await rejection + + // The error still surfaces, but unblocked — the relay-lost ladder retries it on backoff, which is + // how this recovered before owner admission became explicit. + expect(isSshOwnerAdmissionBlocked(selfError)).toBe(false) + }) + it('stops waiting when the relay channel closes', async () => { vi.useFakeTimers() let current = true diff --git a/src/main/ssh/ssh-owner-recovery-retry.ts b/src/main/ssh/ssh-owner-recovery-retry.ts index e5e78d9a4f2..5a958b1dd84 100644 --- a/src/main/ssh/ssh-owner-recovery-retry.ts +++ b/src/main/ssh/ssh-owner-recovery-retry.ts @@ -1,16 +1,57 @@ import { + PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR, + PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR, + PTY_CONSUMER_OWNER_HELD_SELF_ERROR, PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR } from '../../shared/pty-consumer-session' // Why: bound polling when publication is settling or a superseded attempt is closing its transport. export const SSH_OWNER_RECOVERY_WAIT_MS = 3_000 +// Why separate and longer than the relay's grace floor: a disconnected incumbent releases admission +// only after that floor elapses, so this budget must outlast it without borrowing the pending budget. +export const SSH_OWNER_HELD_DISCONNECTED_WAIT_MS = 2_000 +// Why short: the incumbent is this client's own half-open connection, and the relay frees it when its +// keepalive notices — well outside any budget worth blocking a connect on. Poll briefly in case the +// close is already in flight, then let the ordinary relay-lost backoff carry the retry. +export const SSH_OWNER_HELD_SELF_WAIT_MS = 1_000 + +export type SshOwnerRecoveryRetryReason = + | 'publication-pending' + | 'disconnected-holder' + | 'self-holder' + +// Why exported: an attached holder is a decision, not a transport fault, so callers must be able to +// report it as blocked instead of feeding it to reconnect classification as an unexplained failure. +export function isSshOwnerAdmissionBlocked(error: unknown): boolean { + return ( + (error as { code?: unknown } | null | undefined)?.code === + PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR + ) +} + +function retryReasonFor(error: unknown): SshOwnerRecoveryRetryReason | null { + const code = (error as { code?: unknown } | null | undefined)?.code + if ( + code === PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR || + code === PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR + ) { + return 'publication-pending' + } + if (code === PTY_CONSUMER_OWNER_HELD_SELF_ERROR) { + return 'self-holder' + } + // Why an attached holder is deliberately absent: it is blocked, not transient — retrying cannot + // change the answer while another connection is live on the claim. + return code === PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR ? 'disconnected-holder' : null +} const SSH_OWNER_RECOVERY_INITIAL_DELAY_MS = 25 const SSH_OWNER_RECOVERY_MAX_DELAY_MS = 250 type SshOwnerRecoveryRetryGate = { isCurrent: () => boolean onClosed: (listener: () => void) => () => void + onRetryExhausted?: (reason: SshOwnerRecoveryRetryReason) => void } function waitForRetry(delayMs: number, gate: SshOwnerRecoveryRetryGate): Promise { @@ -41,22 +82,32 @@ export async function retrySshOwnerRecoveryWhileBlocked( gate: SshOwnerRecoveryRetryGate, waitMs: number = SSH_OWNER_RECOVERY_WAIT_MS ): Promise { - const deadline = Date.now() + waitMs + const budgetByReason: Record = { + 'publication-pending': waitMs, + 'disconnected-holder': SSH_OWNER_HELD_DISCONNECTED_WAIT_MS, + 'self-holder': SSH_OWNER_HELD_SELF_WAIT_MS + } + // Why each deadline starts when its own phase does: the phases are sequential and independent, so + // charging one reason's budget for time another reason spent leaves it with nothing. A connect that + // waits out a publication first would otherwise get zero attempts at the refusal that follows it. + const deadlineByReason = new Map() let delayMs = SSH_OWNER_RECOVERY_INITIAL_DELAY_MS while (true) { try { return await attempt() } catch (error) { - const code = (error as { code?: unknown } | null | undefined)?.code - if ( - (code !== PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR && - code !== PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR) || - !gate.isCurrent() - ) { + const reason = retryReasonFor(error) + if (reason === null || !gate.isCurrent()) { throw error } + let deadline = deadlineByReason.get(reason) + if (deadline === undefined) { + deadline = Date.now() + budgetByReason[reason] + deadlineByReason.set(reason, deadline) + } const remainingMs = deadline - Date.now() if (remainingMs <= 0) { + gate.onRetryExhausted?.(reason) throw error } await waitForRetry(Math.min(delayMs, remainingMs), gate) diff --git a/src/main/ssh/ssh-pty-consumer-session.test.ts b/src/main/ssh/ssh-pty-consumer-session.test.ts index 76dc25ffacb..a1fc612f1dd 100644 --- a/src/main/ssh/ssh-pty-consumer-session.test.ts +++ b/src/main/ssh/ssh-pty-consumer-session.test.ts @@ -18,6 +18,7 @@ function legacyOwnerGrant(overrides: Record = {}): Record { expectedServerBuildId: 'build-a' }) ).resolves.toEqual({ - mode: 'negotiated', - clientInstanceId: 'client-a', - clientGeneration: 3, - ownerGeneration: 7, - ownerLease: 'lease-a' + state: { + mode: 'negotiated', + clientInstanceId: 'client-a', + clientGeneration: 3, + ownerGeneration: 7, + ownerLease: 'lease-a' + }, + resumed: false }) expect(request).toHaveBeenCalledWith( 'pty.openClient', @@ -51,9 +55,9 @@ describe('openSshPtyConsumerSession', () => { it('carries recovery generation and lease on reconnect', async () => { const { mux, request } = muxReturning( - legacyOwnerGrant({ ownerGeneration: 8, ownerLease: 'lease-b' }) + legacyOwnerGrant({ ownerGeneration: 8, ownerLease: 'lease-a', resumed: true }) ) - await openSshPtyConsumerSession(mux, { + const admission = await openSshPtyConsumerSession(mux, { clientInstanceId: 'client-a', expectedServerBuildId: 'build-a', resume: { ownerGeneration: 7, ownerLease: 'lease-a' } @@ -62,8 +66,30 @@ describe('openSshPtyConsumerSession', () => { expect(request.mock.calls[0][1]).toMatchObject({ resume: { ownerGeneration: 7, ownerLease: 'lease-a' } }) + expect(admission.resumed).toBe(true) }) + it.each([undefined, 'yes', 1, null])( + 'rejects an owner grant that does not state whether the claim was resumed', + async (resumed) => { + const grant = legacyOwnerGrant() + // Why not a legacy peer: the build id already matched, and client and relay ship together. + if (resumed === undefined) { + delete grant.resumed + } else { + grant.resumed = resumed + } + const { mux } = muxReturning(grant) + + await expect( + openSshPtyConsumerSession(mux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a' + }) + ).rejects.toThrow('whether the claim was resumed') + } + ) + it('rejects a prior or mismatched relay build', async () => { const { mux } = muxReturning(legacyOwnerGrant({ serverBuildId: 'old-build' })) @@ -126,9 +152,12 @@ describe('openSshPtyConsumerSession', () => { outputFlowControl: { requestedWindowSu: 64 } }) ).resolves.toEqual({ - mode: 'legacy-fallback', - clientInstanceId: 'client-a', - serverBuildId: 'build-a' + state: { + mode: 'legacy-fallback', + clientInstanceId: 'client-a', + serverBuildId: 'build-a' + }, + resumed: false }) }) diff --git a/src/main/ssh/ssh-pty-consumer-session.ts b/src/main/ssh/ssh-pty-consumer-session.ts index 8bb272d7273..aa58534909b 100644 --- a/src/main/ssh/ssh-pty-consumer-session.ts +++ b/src/main/ssh/ssh-pty-consumer-session.ts @@ -27,6 +27,13 @@ export type SshPtyLegacyFallbackState = { export type SshPtyConsumerSessionState = SshPtyConsumerOwnerState | SshPtyLegacyFallbackState +export type SshPtyConsumerAdmission = { + state: SshPtyConsumerSessionState + // Why not on the owner state itself: this describes one admission's outcome, not the persisted + // claim, and it must never round-trip through the recovery record. + resumed: boolean +} + export type OpenSshPtyConsumerSessionOptions = { clientInstanceId: string expectedServerBuildId: string | undefined @@ -68,6 +75,11 @@ function validateGrant( ) { throw new Error('Remote relay did not grant an authenticated PTY session owner') } + // Why not treated as a legacy relay: client and relay ship in one build, and the build id was already + // matched above — a missing `resumed` here is corruption, not an older peer. + if (typeof grant.resumed !== 'boolean') { + throw new Error('Remote relay owner grant did not state whether the claim was resumed') + } const requestedFlow = options.outputFlowControl const grantedFlow = grant.capabilities?.outputFlowControl if (requestedFlow) { @@ -88,7 +100,7 @@ function validateGrant( export async function openSshPtyConsumerSession( mux: SshChannelMultiplexer, options: OpenSshPtyConsumerSessionOptions -): Promise { +): Promise { let result: unknown try { result = await mux.request( @@ -119,23 +131,29 @@ export async function openSshPtyConsumerSession( typeof options.expectedServerBuildId === 'string' && options.expectedServerBuildId.length > 0 ) { - return Object.freeze({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: options.expectedServerBuildId - }) + return { + state: Object.freeze({ + mode: 'legacy-fallback', + clientInstanceId: options.clientInstanceId, + serverBuildId: options.expectedServerBuildId + }), + resumed: false + } } throw error } const grant = validateGrant(result, options) return { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: grant.clientGeneration, - ownerGeneration: grant.ownerGeneration!, - ownerLease: grant.ownerLease!, - ...(grant.capabilities?.outputFlowControl - ? { outputFlowControl: grant.capabilities.outputFlowControl } - : {}) + state: { + mode: 'negotiated', + clientInstanceId: options.clientInstanceId, + clientGeneration: grant.clientGeneration, + ownerGeneration: grant.ownerGeneration!, + ownerLease: grant.ownerLease!, + ...(grant.capabilities?.outputFlowControl + ? { outputFlowControl: grant.capabilities.outputFlowControl } + : {}) + }, + resumed: grant.resumed! } } diff --git a/src/main/ssh/ssh-pty-frame-rejection.ts b/src/main/ssh/ssh-pty-frame-rejection.ts new file mode 100644 index 00000000000..07dcf3827cd --- /dev/null +++ b/src/main/ssh/ssh-pty-frame-rejection.ts @@ -0,0 +1,115 @@ +import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' +import type { SshPtyConsumerOwnerState } from './ssh-pty-consumer-session' + +type SshPtyDataPayload = Parameters[0] + +const SSH_PTY_FRAME_REJECTION_LOG_KEY_LIMIT = 12 + +export type SshPtyFrameRejectionReason = + | 'source-missing' + | 'source-malformed' + | 'client-generation-stale' + | 'owner-generation-stale' + | 'client-and-owner-generation-stale' + | 'source-range-invalid' + +export type SshPtyFrameRejection = Readonly<{ + reason: SshPtyFrameRejectionReason + action: 'quarantine-legacy-frame' | 'retire-and-reattach-delivery' +}> + +// Why a source-less frame is quarantined rather than reattached: it is the signature of legacy +// output already in flight when the flow-control grant landed, not of a broken delivery. The relay's +// own admission stops publishing those once the grant is live, and source recovery replays from +// sourceStartSu, so dropping them loses nothing. Reattaching instead would restart the PTY on every +// openClient handshake. +export function classifySshPtyFrameRejection( + payload: SshPtyDataPayload, + owner: SshPtyConsumerOwnerState | null +): SshPtyFrameRejection | null { + if (payload.sourceMalformed) { + return Object.freeze({ + reason: 'source-malformed', + action: 'retire-and-reattach-delivery' + }) + } + const source = payload.source + if (!source) { + return owner?.outputFlowControl + ? Object.freeze({ + reason: 'source-missing', + action: 'quarantine-legacy-frame' + }) + : null + } + if (!owner?.outputFlowControl) { + return payload.sourceRejected + ? Object.freeze({ + reason: 'source-range-invalid', + action: 'retire-and-reattach-delivery' + }) + : null + } + const staleClient = source.clientGeneration !== owner.clientGeneration + const staleOwner = source.ownerGeneration !== owner.ownerGeneration + if (staleClient || staleOwner) { + return Object.freeze({ + reason: + staleClient && staleOwner + ? 'client-and-owner-generation-stale' + : staleClient + ? 'client-generation-stale' + : 'owner-generation-stale', + action: 'retire-and-reattach-delivery' + }) + } + if (payload.sourceRejected) { + return Object.freeze({ + reason: 'source-range-invalid', + action: 'retire-and-reattach-delivery' + }) + } + return null +} + +/** + * Why bounded: a quarantined frame carries no delivery token to retire, so nothing dedupes it — a + * relay that keeps publishing legacy output to a flow-control owner would log once per frame and + * bury every other diagnostic. One line per PTY and reason per generation names the fault; the + * generation is itself the counter for how often it recurs. + */ +export class SshPtyFrameRejectionLog { + private generation: number | null = null + private readonly loggedKeys = new Set() + + record( + payload: SshPtyDataPayload, + owner: SshPtyConsumerOwnerState | null, + rejection: SshPtyFrameRejection + ): void { + if (this.generation !== payload.providerGeneration) { + this.generation = payload.providerGeneration + this.loggedKeys.clear() + } + const key = `${payload.id}\0${rejection.reason}` + if (this.loggedKeys.has(key) || this.loggedKeys.size >= SSH_PTY_FRAME_REJECTION_LOG_KEY_LIMIT) { + return + } + this.loggedKeys.add(key) + console.warn('[ssh-relay-session] Rejected PTY delivery', { + ptyId: payload.id, + providerGeneration: payload.providerGeneration, + expectedClientGeneration: owner?.clientGeneration ?? null, + expectedOwnerGeneration: owner?.ownerGeneration ?? null, + offeredClientGeneration: payload.source?.clientGeneration ?? null, + offeredOwnerGeneration: payload.source?.ownerGeneration ?? null, + sourceState: rejection.reason, + recoveryAction: rejection.action + }) + } + + clear(): void { + this.generation = null + this.loggedKeys.clear() + } +} diff --git a/src/main/ssh/ssh-pty-targeted-reattach-queue.ts b/src/main/ssh/ssh-pty-targeted-reattach-queue.ts new file mode 100644 index 00000000000..23329abf587 --- /dev/null +++ b/src/main/ssh/ssh-pty-targeted-reattach-queue.ts @@ -0,0 +1,57 @@ +type TargetedReattach = Readonly<{ start: () => void }> + +// Why bounded: every rejected frame asks for its own attach round trip, so a relay that starts +// rejecting across many PTYs at once fans out one concurrent reattach per PTY. The bulk reconnect +// path caps the identical work, and each attach also costs a lease read and a lease write. +export class SshPtyTargetedReattachQueue { + private readonly running = new Map() + private readonly waiting: TargetedReattach[] = [] + private active = 0 + + constructor(private readonly maxConcurrency: number) {} + + has(key: string): boolean { + return this.running.has(key) + } + + run(key: string, task: () => Promise): Promise { + return new Promise((resolve, reject) => { + const entry: TargetedReattach = { + start: () => { + this.active++ + task().then( + (recovered) => { + this.settle(key, entry) + resolve(recovered) + }, + (error: unknown) => { + this.settle(key, entry) + reject(error instanceof Error ? error : new Error(String(error))) + } + ) + } + } + this.running.set(key, entry) + if (this.active < this.maxConcurrency) { + entry.start() + return + } + this.waiting.push(entry) + }) + } + + // Why queued entries are dropped rather than started: each one is keyed to the provider generation + // the teardown just ended, so running it would attach onto a mux that is already gone. + clear(): void { + this.waiting.length = 0 + this.running.clear() + } + + private settle(key: string, entry: TargetedReattach): void { + if (this.running.get(key) === entry) { + this.running.delete(key) + } + this.active-- + this.waiting.shift()?.start() + } +} diff --git a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts new file mode 100644 index 00000000000..9d08ac7da0f --- /dev/null +++ b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshRelaySession } from './ssh-relay-session' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' + +const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ + muxRequestMock: vi.fn(), + openConsumerSessionMock: vi.fn(async (_mux: unknown, options: { clientInstanceId: string }) => ({ + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'test-owner-lease' + })) +})) + +vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) +vi.mock('./ssh-pty-consumer-session', () => ({ + openSshPtyConsumerSession: openConsumerSessionMock +})) +vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ + acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), + acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), + allocateSshPtyProviderGeneration: vi.fn(() => 17), + beginSshPtyOutputGenerationMigration: vi.fn(() => ({ + byPty: new Map(), + completion: Promise.resolve() + })), + closeSshPtyOutputGeneration: vi.fn(), + getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), + applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), + installSshPtySourceAckPublisher: vi.fn(() => () => {}), + installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) +})) +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) +vi.mock('./ssh-remote-orca-cli', () => ({ + runRemoteOrcaCli: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) +})) +vi.mock('./ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class MockSshChannelMultiplexer { + notify = vi.fn() + notifyWithSettlement = vi.fn() + request = muxRequestMock + onNotification = vi.fn().mockReturnValue(() => {}) + onNotificationByMethod = vi.fn().mockReturnValue(() => {}) + onRequest = vi.fn().mockReturnValue(() => {}) + onDispose = vi.fn().mockReturnValue(() => {}) + dispose = vi.fn() + isDisposed = vi.fn().mockReturnValue(false) + } +})) +vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ + installRemoteManagedAgentHooks: vi.fn() +})) +vi.mock('../providers/ssh-pty-provider', () => ({ + SshPtyProvider: class MockSshPtyProvider { + onData = vi.fn().mockReturnValue(() => {}) + onReplay = vi.fn().mockReturnValue(() => {}) + onExit = vi.fn().mockReturnValue(() => {}) + attach = vi.fn().mockResolvedValue(undefined) + attachForReconnect = vi.fn().mockResolvedValue({}) + dispose = vi.fn() + } +})) +vi.mock('../providers/ssh-filesystem-provider', () => ({ + SshFilesystemProvider: class MockSshFilesystemProvider { + dispose = vi.fn() + } +})) +vi.mock('../providers/ssh-git-provider', () => ({ + SshGitProvider: class MockSshGitProvider {} +})) +vi.mock('../ipc/pty', () => ({ + registerSshPtyProvider: vi.fn(), + unregisterSshPtyProvider: vi.fn(), + getSshPtyProvider: vi.fn(), + getPtyIdsForConnection: vi.fn().mockReturnValue([]), + clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn(), + setPtyOwnership: vi.fn(), + restorePtyIncarnation: vi.fn(), + isCurrentPtyExit: vi.fn(() => true), + answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) +})) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + registerSshFilesystemProvider: vi.fn(), + unregisterSshFilesystemProvider: vi.fn(), + getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + registerSshGitProvider: vi.fn(), + unregisterSshGitProvider: vi.fn() +})) + +const { getSshPtyProvider, getPtyIdsForConnection, clearProviderPtyState, deletePtyOwnership } = + await import('../ipc/pty') + +const APP_PTY_ID = 'ssh:target-1@@pty-live' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function detachedLease() { + return { + targetId: 'target-1', + ptyId: 'pty-live', + state: 'detached' as const, + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: LEAF_ID + } +} + +/** + * STA-3376: every reattach failure walks away from a remote PTY that may still be running a user's + * work. Orca's rule is that unprovable liveness never authorizes destroying a session, so these + * assert the opposite of a leak test: the shell must survive, and the lease must stay in a state + * the user-facing terminate action can still reach. + */ +describe('SshRelaySession abandoned remote PTYs', () => { + beforeEach(() => { + vi.clearAllMocks() + muxRequestMock.mockReset() + muxRequestMock.mockResolvedValue([]) + mockDeploySuccess() + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + }) + + async function establishWithFailingReattach(error: Error): Promise<{ + deps: ReturnType + shutdown: ReturnType + attachForReconnect: ReturnType + }> { + const deps = createMockDeps() + const shutdown = vi.fn().mockResolvedValue(undefined) + const attachForReconnect = vi.fn().mockRejectedValue(error) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect, + shutdown, + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< + typeof deps.mockStore.getSshRemotePtyLeases + >) + const session = new SshRelaySession( + 'target-1', + deps.getMainWindow, + deps.mockStore, + deps.mockPortForward + ) + + await session.establish(deps.mockConn) + + return { deps, shutdown, attachForReconnect } + } + + it('leaves a live shell running and reachable when reattach attempts are exhausted', async () => { + // A transport stall proves nothing about the remote shell; the user's long-running process + // may be untouched, so the lease must stay terminable rather than be tombstoned as expired. + const { deps, shutdown, attachForReconnect } = await establishWithFailingReattach( + new Error('PTY reattach attempt timed out after 15000ms') + ) + + expect(attachForReconnect).toHaveBeenCalled() + expect(shutdown).not.toHaveBeenCalled() + expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'target-1', + 'pty-live', + 'expired' + ) + expect(deletePtyOwnership).not.toHaveBeenCalledWith(APP_PTY_ID) + expect(deps.mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) + }) + + it('leaves another pane live shell running when the relay reports an identity mismatch', async () => { + // The relay answered that a *live* PTY holds this id under a different pane identity. Killing + // it would destroy an unrelated terminal, so this path may only stop claiming the id. + const { deps, shutdown, attachForReconnect } = await establishWithFailingReattach( + new Error('PTY "pty-live" not found (identity mismatch)') + ) + + expect(attachForReconnect).toHaveBeenCalled() + expect(shutdown).not.toHaveBeenCalled() + expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'target-1', + 'pty-live', + 'expired' + ) + expect(clearProviderPtyState).not.toHaveBeenCalledWith(APP_PTY_ID) + }) + + it('retires the lease without a kill when the relay proves the PTY is gone', async () => { + // pty.attach verifies process liveness before answering not-found, so this is the one branch + // with positive proof of death — and a dead process needs no shutdown request. + const { deps, shutdown } = await establishWithFailingReattach( + new Error('PTY "pty-live" not found') + ) + + expect(shutdown).not.toHaveBeenCalled() + expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'target-1', + 'pty-live', + 'expired' + ) + expect(clearProviderPtyState).toHaveBeenCalledWith(APP_PTY_ID) + expect(deps.mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: APP_PTY_ID, + code: -1 + }) + }) + + it('keeps a recovered session attached when reattach succeeds after an earlier drop', async () => { + const deps = createMockDeps() + const shutdown = vi.fn().mockResolvedValue(undefined) + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: vi.fn().mockResolvedValue({ incarnationId: 'incarnation-1' }), + shutdown, + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< + typeof deps.mockStore.getSshRemotePtyLeases + >) + const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() } + const session = new SshRelaySession( + 'target-1', + deps.getMainWindow, + deps.mockStore, + deps.mockPortForward, + runtime as never + ) + + await session.establish(deps.mockConn) + + expect(shutdown).not.toHaveBeenCalled() + expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { + tabId: 'tab-1', + leafId: LEAF_ID, + incarnationId: 'incarnation-1' + }) + expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'target-1', + 'pty-live', + 'expired' + ) + }) +}) diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 1a384a32f72..f54afef3f89 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -111,6 +111,7 @@ function createFakeRelay(): FakeRelay { ? (params.resume as { ownerGeneration: number }).ownerGeneration + 1 : 1, ownerLease: 'test-owner-lease', + resumed: params.resume !== undefined, capabilities: { outputFlowControl: { version: 1, windowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } } diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts index 56f6c6eeff2..2eca6802f28 100644 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -1,10 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SshRelaySession } from './ssh-relay-session' -import { - createMismatchedOwnerRecoveryError, - createMockDeps, - mockDeploySuccess -} from './ssh-relay-session-test-fixtures' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' const { acceptOutputDataMock, @@ -145,16 +141,22 @@ describe('SshRelaySession data delivery', () => { vi.mocked(applySshPtySourceCancellationProof).mockReturnValue(true) vi.mocked(applySshPtySourceRecoveryCancellationProof).mockReturnValue(true) openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease', - ...(options.outputFlowControl - ? { - outputFlowControl: { version: 1, windowSu: options.outputFlowControl.requestedWindowSu } - } - : {}) + state: { + mode: 'negotiated', + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'test-owner-lease', + ...(options.outputFlowControl + ? { + outputFlowControl: { + version: 1, + windowSu: options.outputFlowControl.requestedWindowSu + } + } + : {}) + }, + resumed: options.resume !== undefined })) muxRequestMock.mockResolvedValue([]) mockDeploySuccess() @@ -173,12 +175,15 @@ describe('SshRelaySession data delivery', () => { ) let generation = 0 openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: ++generation, - ownerGeneration: generation, - ownerLease: `owner-lease-${generation}`, - outputFlowControl: { version: 1, windowSu: 256 * 1024 } + state: { + mode: 'negotiated', + clientInstanceId: options.clientInstanceId, + clientGeneration: ++generation, + ownerGeneration: generation, + ownerLease: `owner-lease-${generation}`, + outputFlowControl: { version: 1, windowSu: 256 * 1024 } + }, + resumed: options.resume !== undefined })) vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ { @@ -342,7 +347,7 @@ describe('SshRelaySession data delivery', () => { expect(mockStore.removeSshPtyConsumerRecovery).toHaveBeenCalledWith(targetId) }) - it('clears stale owner recovery and retries a fresh same-build relay once without resume', async () => { + it('voids checkpoints for a fresh claim without a second owner request', async () => { const targetId = 'fresh-relay-retry' const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ @@ -365,16 +370,17 @@ describe('SshRelaySession data delivery', () => { await first.establish(mockConn) first.detach() - openConsumerSessionMock - .mockRejectedValueOnce(createMismatchedOwnerRecoveryError()) - .mockImplementationOnce(async (_mux, options) => ({ + openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ + state: { mode: 'negotiated', clientInstanceId: options.clientInstanceId, clientGeneration: 1, ownerGeneration: 1, ownerLease: 'fresh-owner-lease', outputFlowControl: { version: 1, windowSu: 256 * 1024 } - })) + }, + resumed: false + })) vi.mocked(getPtyIdsForConnection).mockReturnValue([`ssh:${targetId}@@pty-1`]) vi.mocked(getSshPtyProvider).mockImplementation( () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] @@ -391,9 +397,10 @@ describe('SshRelaySession data delivery', () => { const retryCalls = openConsumerSessionMock.mock.calls .slice(openCallCountBeforeRetry) .map(([, options]) => options) - expect(retryCalls).toHaveLength(2) + // Why one call: the relay answers a proof it cannot match with a fresh claim, so the client never + // needs a second, resume-less request to get owner authority back. + expect(retryCalls).toHaveLength(1) expect(retryCalls[0]).toHaveProperty('resume') - expect(retryCalls[1]).not.toHaveProperty('resume') expect(attachForReconnectMock).toHaveBeenCalledWith( 'pty-1', undefined, @@ -512,7 +519,7 @@ describe('SshRelaySession data delivery', () => { ) }) - it('rejects missing negotiated source identity before main admission', async () => { + it('quarantines missing negotiated source identity before main admission', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) @@ -529,10 +536,8 @@ describe('SshRelaySession data delivery', () => { }) expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(closeSshPtyOutputGeneration).toHaveBeenCalledWith( - 23, - 'ssh_source_frame_malformed_or_missing' - ) + expect(closeSshPtyOutputGeneration).not.toHaveBeenCalled() + expect(muxDisposeMock).not.toHaveBeenCalled() }) it('keeps unoffered source metadata out of legacy intake', async () => { @@ -644,12 +649,15 @@ describe('SshRelaySession data delivery', () => { openConsumerSessionMock.mockImplementation(async (_mux, options) => { generation++ return { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: generation, - ownerGeneration: generation, - ownerLease: `owner-lease-${generation}`, - outputFlowControl: { version: 1, windowSu: 256 * 1024 } + state: { + mode: 'negotiated', + clientInstanceId: options.clientInstanceId, + clientGeneration: generation, + ownerGeneration: generation, + ownerLease: `owner-lease-${generation}`, + outputFlowControl: { version: 1, windowSu: 256 * 1024 } + }, + resumed: options.resume !== undefined } }) vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ diff --git a/src/main/ssh/ssh-relay-session-model-migration.test.ts b/src/main/ssh/ssh-relay-session-model-migration.test.ts index 9bd2216357d..ae5db55e0cc 100644 --- a/src/main/ssh/ssh-relay-session-model-migration.test.ts +++ b/src/main/ssh/ssh-relay-session-model-migration.test.ts @@ -1,10 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SshRelaySession } from './ssh-relay-session' -import { - createMismatchedOwnerRecoveryError, - createMockDeps, - mockDeploySuccess -} from './ssh-relay-session-test-fixtures' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' type SettledMigration = { status: 'settled' @@ -135,16 +131,22 @@ describe('SshRelaySession model migration', () => { vi.mocked(getPtyIdsForConnection).mockReturnValue([]) vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'owner-lease-1', - ...(options.outputFlowControl - ? { - outputFlowControl: { version: 1, windowSu: options.outputFlowControl.requestedWindowSu } - } - : {}) + state: { + mode: 'negotiated', + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'owner-lease-1', + ...(options.outputFlowControl + ? { + outputFlowControl: { + version: 1, + windowSu: options.outputFlowControl.requestedWindowSu + } + } + : {}) + }, + resumed: options.resume !== undefined })) muxRequestMock.mockResolvedValue([]) mockDeploySuccess() @@ -209,7 +211,7 @@ describe('SshRelaySession model migration', () => { ) }) - it('waits for a stale-owner migration before requesting restore', async () => { + it('waits for a voided-checkpoint migration before requesting restore', async () => { const targetId = 'migration-stale-owner' const appPtyId = `ssh:${targetId}@@pty-1` const migration = pendingMigration() @@ -232,23 +234,24 @@ describe('SshRelaySession model migration', () => { vi.mocked(getSshPtyProvider).mockImplementation( () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] ) - openConsumerSessionMock - .mockRejectedValueOnce(createMismatchedOwnerRecoveryError()) - .mockImplementationOnce(async (_mux, options) => ({ + openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ + state: { mode: 'negotiated', clientInstanceId: options.clientInstanceId, clientGeneration: 2, ownerGeneration: 2, ownerLease: 'fresh-owner-lease', outputFlowControl: { version: 1, windowSu: 256 * 1024 } - })) + }, + resumed: false + })) attachForReconnectMock.mockResolvedValue({ incarnationId: 'incarnation-1', sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } }) const reconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(openConsumerSessionMock).toHaveBeenCalledTimes(3)) + await vi.waitFor(() => expect(openConsumerSessionMock).toHaveBeenCalledTimes(2)) expect(attachForReconnectMock).not.toHaveBeenCalled() migration.resolve(settledMigration(appPtyId, 8)) diff --git a/src/main/ssh/ssh-relay-session-recovery-durability.test.ts b/src/main/ssh/ssh-relay-session-recovery-durability.test.ts index 6e1fe81c5a5..ed0136f9110 100644 --- a/src/main/ssh/ssh-relay-session-recovery-durability.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-durability.test.ts @@ -1,13 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR, + PTY_CONSUMER_OWNER_HELD_SELF_ERROR, PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR } from '../../shared/pty-consumer-session' import { SshRelaySession } from './ssh-relay-session' -import { - createMismatchedOwnerRecoveryError, - createMockDeps -} from './ssh-relay-session-test-fixtures' +import { createMockDeps } from './ssh-relay-session-test-fixtures' import { getSshPtyConsumerRecovery } from './ssh-pty-consumer-recovery' const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ @@ -108,11 +107,14 @@ describe('SshRelaySession consumer recovery durability', () => { vi.clearAllMocks() muxRequestMock.mockResolvedValue([]) openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' + state: { + mode: 'negotiated', + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'test-owner-lease' + }, + resumed: false })) vi.mocked(deployAndLaunchRelay).mockResolvedValue({ transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, @@ -277,8 +279,68 @@ describe('SshRelaySession consumer recovery durability', () => { expect(completed).toBe(true) }) - it('does not retry a stale owner after disposal wins the recovery-removal race', async () => { - const targetId = 'target-stale-owner-disposal' + it('reports an attached owner as terminal instead of a lost relay', async () => { + const targetId = 'target-owner-held-attached' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + const onTerminal = vi.fn() + const onRelayLost = vi.fn() + session.setOnTerminalRelayError(onTerminal) + session.setOnRelayLost(onRelayLost) + await session.establish(mockConn) + + openConsumerSessionMock.mockRejectedValueOnce( + Object.assign(new Error('PTY session owner is held by an attached connection'), { + code: PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR + }) + ) + await session.reconnect(mockConn) + + // Why not relay-lost: the relay answered and refused on purpose, so redeploy backoff would spend + // its whole budget on a link that is working. + expect(onRelayLost).not.toHaveBeenCalled() + expect(onTerminal).toHaveBeenCalledWith( + targetId, + expect.objectContaining({ name: 'SshOwnerAdmissionBlockedError' }) + ) + expect(onTerminal.mock.calls[0]?.[1]?.message).toContain('owns the remote terminals') + expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() + expect(openConsumerSessionMock).toHaveBeenCalledTimes(2) + session.dispose() + }) + + it("routes this client's own attached connection to relay-lost recovery, not to a parked error", async () => { + const targetId = 'target-owner-held-self' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + const onTerminal = vi.fn() + const onRelayLost = vi.fn() + session.setOnTerminalRelayError(onTerminal) + session.setOnRelayLost(onRelayLost) + await session.establish(mockConn) + + // The incumbent is this app's own half-open connection, which the relay has not yet observed + // closing — the ordinary single-app case, since only SshRelaySession ever requests session-owner. + openConsumerSessionMock.mockRejectedValue( + Object.assign( + new Error("PTY session owner is held by this client's own earlier connection"), + { + code: PTY_CONSUMER_OWNER_HELD_SELF_ERROR + } + ) + ) + await session.reconnect(mockConn) + + // Why not terminal: parking here clears backoff and never retries, so the user stays locked out + // of their own session until they restart the app. Backoff is what lets keepalive reap the zombie. + expect(onTerminal).not.toHaveBeenCalled() + expect(onRelayLost).toHaveBeenCalled() + expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() + session.dispose() + }) + + it('recovers a forgotten owner record in one request without deleting recovery identity', async () => { + const targetId = 'target-forgotten-owner-record' const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ targetId, @@ -286,30 +348,75 @@ describe('SshRelaySession consumer recovery durability', () => { serverBuildId: 'test-relay-build', clientGeneration: 1, ownerGeneration: 1, - ownerLease: 'stale-owner' + ownerLease: 'forgotten-owner' }) - let signalRemovalStarted!: () => void - const removalStarted = new Promise((resolve) => { - signalRemovalStarted = resolve - }) - let settleRemoval!: () => void - vi.mocked(mockStore.removeSshPtyConsumerRecovery).mockImplementationOnce(() => { - signalRemovalStarted() - return new Promise((resolve) => { - settleRemoval = resolve - }) - }) - openConsumerSessionMock.mockRejectedValueOnce(createMismatchedOwnerRecoveryError()) + openConsumerSessionMock.mockImplementationOnce(async () => ({ + state: { + mode: 'negotiated', + clientInstanceId: 'persisted-client', + clientGeneration: 4, + ownerGeneration: 9, + ownerLease: 'fresh-owner' + }, + resumed: false + })) const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.set('pty-1', { + id: 'pty-1' + } as unknown as never) - const establishing = session.establish(mockConn) - const failed = expect(establishing).rejects.toThrow('Session disposed during establish') - await removalStarted - const disposal = session.disposeAndPersist() - settleRemoval() - await Promise.all([failed, disposal]) + await session.establish(mockConn) expect(openConsumerSessionMock).toHaveBeenCalledTimes(1) + expect(openConsumerSessionMock.mock.calls[0]?.[1]).toMatchObject({ + resume: { ownerGeneration: 1, ownerLease: 'forgotten-owner' } + }) + // Why both: the fresh claim voids checkpoints taken under the old lease, but the recovery identity + // is what lets this client keep resuming the target at all — a refusal must never cost it. + expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.size).toBe(0) + expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() + expect(mockStore.upsertSshPtyConsumerRecovery).toHaveBeenCalledWith( + expect.objectContaining({ + targetId, + clientInstanceId: 'persisted-client', + ownerGeneration: 9, + ownerLease: 'fresh-owner' + }) + ) + session.dispose() + }) + + it('keeps checkpoints when a resumed claim comes back', async () => { + const targetId = 'target-resumed-owner-record' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ + targetId, + clientInstanceId: 'persisted-client', + serverBuildId: 'test-relay-build', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'persisted-owner' + }) + openConsumerSessionMock.mockImplementationOnce(async () => ({ + state: { + mode: 'negotiated', + clientInstanceId: 'persisted-client', + clientGeneration: 4, + ownerGeneration: 2, + ownerLease: 'persisted-owner' + }, + resumed: true + })) + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.set('pty-1', { + id: 'pty-1' + } as unknown as never) + + await session.establish(mockConn) + + expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.has('pty-1')).toBe(true) + expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() + session.dispose() }) it('leaves recovery state alone when a newer owner already claimed the target record', async () => { @@ -330,28 +437,84 @@ describe('SshRelaySession consumer recovery durability', () => { ownerGeneration: 5, ownerLease: 'winner-owner' } - openConsumerSessionMock.mockImplementationOnce(() => { - // Why inside the rejection: the record is target-scoped, so the winner can land while this - // attempt is still unwinding its own resume. + openConsumerSessionMock.mockImplementationOnce(async () => { + // Why inside the open: the record is target-scoped, so the winner can land while this attempt is + // still waiting on its own resume, leaving this attempt's `previousOwner` snapshot stale. const record = getSshPtyConsumerRecovery(targetId)! record.owner = winner - record.checkpointsByAppPtyId.set('pty-1', { - id: 'pty-1' - } as unknown as never) - return Promise.reject(createMismatchedOwnerRecoveryError()) + record.checkpointsByAppPtyId.set('pty-1', { id: 'pty-1' } as unknown as never) + return { + state: { + mode: 'negotiated', + clientInstanceId: 'persisted-client', + clientGeneration: 3, + ownerGeneration: 1, + ownerLease: 'loser-owner' + }, + resumed: false + } }) - openConsumerSessionMock.mockRejectedValueOnce(new Error('fresh open failed')) const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await expect(session.establish(mockConn)).rejects.toThrow('fresh open failed') + await session.establish(mockConn) - const record = getSshPtyConsumerRecovery(targetId)! - expect(record.owner).toBe(winner) - expect(record.checkpointsByAppPtyId.has('pty-1')).toBe(true) + expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.has('pty-1')).toBe(true) expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() session.dispose() }) + it('mutates no recovery state after a local attempt loses its authority', async () => { + const targetId = 'target-owner-attempt-superseded' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ + targetId, + clientInstanceId: 'persisted-client', + serverBuildId: 'test-relay-build', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'persisted-owner' + }) + let signalOpenStarted!: () => void + const openStarted = new Promise((resolve) => { + signalOpenStarted = resolve + }) + let finishOpen!: (value: unknown) => void + openConsumerSessionMock.mockImplementationOnce(() => { + signalOpenStarted() + return new Promise((resolve) => { + finishOpen = resolve + }) + }) + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.set('pty-1', { + id: 'pty-1' + } as unknown as never) + + const superseded = session.establish(mockConn) + const failed = expect(superseded).rejects.toThrow('Session disposed during establish') + await openStarted + // Why detach and not disposal: disposal legitimately removes the record, which would hide whether + // the attempt that lost its authority mutated anything on its way out. + await session.detachAndPersist() + finishOpen({ + state: { + mode: 'negotiated', + clientInstanceId: 'persisted-client', + clientGeneration: 3, + ownerGeneration: 3, + ownerLease: 'superseded-owner' + }, + resumed: false + }) + await failed + + expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.has('pty-1')).toBe(true) + expect(mockStore.upsertSshPtyConsumerRecovery).not.toHaveBeenCalledWith( + expect.objectContaining({ ownerLease: 'superseded-owner' }) + ) + expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() + }) + it('does not remember a consumer opened after establish was disposed', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() let signalOpenStarted!: () => void @@ -377,17 +540,59 @@ describe('SshRelaySession consumer recovery durability', () => { await openStarted await session.disposeAndPersist() finishOpen({ - mode: 'negotiated', - clientInstanceId: 'late-client', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'late-owner' + state: { + mode: 'negotiated', + clientInstanceId: 'late-client', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'late-owner' + }, + resumed: false }) await failed expect(mockStore.upsertSshPtyConsumerRecovery).not.toHaveBeenCalled() }) + it('detaches for shutdown in memory without a per-session durable write', async () => { + const targetId = 'target-shutdown-detach' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockClear() + + session.beginShutdownDetach() + session.beginShutdownDetach() + + // Why in-memory only: the committed quit path runs the final store flush immediately after this, + // and a durable write here would race that flush rather than be captured by it. + expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledExactlyOnceWith( + targetId, + 'detached' + ) + expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalled() + // Why the recovery record survives: 'detached' means this app let go, not that the remote shell + // died — the identity is what lets the next launch reattach those still-running PTYs. + expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() + expect(getSshPtyConsumerRecovery(targetId)?.detached).toBe(true) + expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBeTypeOf('string') + }) + + it('keeps ordinary detach durability reporting unchanged after a shutdown detach exists', async () => { + const targetId = 'target-shutdown-vs-ordinary-detach' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockRejectedValueOnce( + new Error('lease write failed') + ) + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + + // Why still rejecting: only the shutdown path delegates durability; an ordinary detach must keep + // surfacing a failed write to its caller. + await expect(session.detachAndPersist()).rejects.toThrow('lease write failed') + expect(mockStore.markSshRemotePtyLeasesForShutdown).not.toHaveBeenCalled() + }) + it('upgrades a pending detach to a full disposal', async () => { const targetId = 'target-teardown-upgrade' const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 81039d901ec..26dd798ac5a 100644 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-races.test.ts @@ -191,12 +191,15 @@ describe('SshRelaySession recovery race fencing', () => { }> { let generation = 0 openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: ++generation, - ownerGeneration: generation, - ownerLease: `owner-lease-${generation}`, - outputFlowControl: { version: 1, windowSu: 256 * 1024 } + state: { + mode: 'negotiated', + clientInstanceId: options.clientInstanceId, + clientGeneration: ++generation, + ownerGeneration: generation, + ownerLease: `owner-lease-${generation}`, + outputFlowControl: { version: 1, windowSu: 256 * 1024 } + }, + resumed: options.resume !== undefined })) vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ { @@ -417,6 +420,7 @@ describe('SshRelaySession recovery race fencing', () => { }) await session.reconnect(deps.mockConn) const closeCount = vi.mocked(closeSshPtyOutputGeneration).mock.calls.length + const muxDisposeCount = muxDisposeMock.mock.calls.length emitSourceFrame({ targetId, @@ -428,11 +432,9 @@ describe('SshRelaySession recovery race fencing', () => { }) expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(closeSshPtyOutputGeneration).toHaveBeenCalledTimes(closeCount + 1) - expect(closeSshPtyOutputGeneration).toHaveBeenLastCalledWith( - 23, - 'ssh_source_frame_stale_or_non_contiguous' - ) + expect(closeSshPtyOutputGeneration).toHaveBeenCalledTimes(closeCount) + expect(muxDisposeMock).toHaveBeenCalledTimes(muxDisposeCount) + await vi.waitFor(() => expect(attachForReconnectMock).toHaveBeenCalledTimes(2)) }) it('drops late frames from a token after its cancellation proof is validated', async () => { diff --git a/src/main/ssh/ssh-relay-session-rejected-delivery.test.ts b/src/main/ssh/ssh-relay-session-rejected-delivery.test.ts new file mode 100644 index 00000000000..e0f1572d4d3 --- /dev/null +++ b/src/main/ssh/ssh-relay-session-rejected-delivery.test.ts @@ -0,0 +1,496 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' +import type { SshPtyProvider } from '../providers/ssh-pty-provider' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import type { SshPtyConsumerSessionState } from './ssh-pty-consumer-session' +import { SshRelaySession } from './ssh-relay-session' +import { createMockDeps } from './ssh-relay-session-test-fixtures' + +const { acceptOutputDataMock, getSshPtyProviderMock } = vi.hoisted(() => ({ + acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), + getSshPtyProviderMock: vi.fn() +})) + +vi.mock('../ipc/ssh-pty-output-intake-registry', async (importOriginal) => { + const original = (await importOriginal()) as object + return { ...original, acceptSshPtyOutputData: acceptOutputDataMock } +}) + +vi.mock('../ipc/pty', async (importOriginal) => { + const original = (await importOriginal()) as object + return { ...original, getSshPtyProvider: getSshPtyProviderMock } +}) + +type SshPtyDataPayload = Parameters[0] + +type RejectedDeliverySession = { + mux: SshChannelMultiplexer | null + activePtyProviderGeneration: number | null + ptyConsumerSessionState: SshPtyConsumerSessionState | null + acceptPtyData: (payload: SshPtyDataPayload) => Promise + reattachKnownPty: (args: { + ptyId: string + activeLeaseByPtyId: Map + expectedIdentityByPtyId: Map + attachedLeaseIds: Set + targetedDeliveryRecovery?: 'confirm-existing' | 'fresh-activation' + }) => Promise + reattachRejectedPty: ( + relayPtyId: string, + mux: SshChannelMultiplexer, + providerGeneration: number, + targetedDeliveryRecovery: 'confirm-existing' | 'fresh-activation' + ) => Promise + sourceRecoveryRequest: (appPtyId: string) => Promise< + | { + status: 'checkpoint' + clientGeneration: number + ownerGeneration: number + ptyIncarnation: string + deliveryToken: string + acceptedSourceEndSu: number + } + | undefined + > + rejectedPtyRecoveryAttempts: Map + sourceIdentityByRelayPtyId: Map + retireExitedPty: (payload: { + id: string + code: number + providerGeneration: number + ptyIncarnation: string + }) => void +} + +function source(overrides: Partial> = {}) { + return { + relayPtyId: 'pty-bad', + spanId: 'token-bad:0:4', + clientGeneration: 1, + ownerGeneration: 1, + deliveryToken: 'token-bad', + sourceStartSu: 0, + sourceEndSu: 4, + ...overrides + } +} + +function rejectedPayload(overrides: Partial = {}): SshPtyDataPayload { + return { + id: 'ssh:target-1@@pty-bad', + data: 'rejected', + providerGeneration: 23, + ptyIncarnation: 'incarnation-bad', + source: source(), + sourceRejected: true, + ...overrides + } +} + +function prepareSession() { + const deps = createMockDeps() + const mux = { + isDisposed: vi.fn(() => false), + dispose: vi.fn() + } as unknown as SshChannelMultiplexer + const session = new SshRelaySession( + 'target-1', + deps.getMainWindow, + deps.mockStore, + deps.mockPortForward + ) + const internals = session as unknown as RejectedDeliverySession + internals.mux = mux + internals.activePtyProviderGeneration = 23 + internals.ptyConsumerSessionState = { + mode: 'negotiated', + clientInstanceId: 'client-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'owner-lease', + outputFlowControl: { version: 1, windowSu: 64 } + } + return { deps, internals, mux, session } +} + +describe('SshRelaySession rejected PTY delivery recovery', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('quarantines source-less output queued before a flow-control grant', async () => { + const { internals, mux } = prepareSession() + const reattach = vi.fn().mockResolvedValue(true) + internals.reattachRejectedPty = reattach + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await internals.acceptPtyData(rejectedPayload({ source: undefined, sourceRejected: undefined })) + + expect(warn).toHaveBeenCalledWith( + '[ssh-relay-session] Rejected PTY delivery', + expect.objectContaining({ + sourceState: 'source-missing', + recoveryAction: 'quarantine-legacy-frame' + }) + ) + expect(reattach).not.toHaveBeenCalled() + expect(acceptOutputDataMock).not.toHaveBeenCalled() + expect(mux.dispose).not.toHaveBeenCalled() + }) + + it.each([ + ['client', 9, 1], + ['owner', 1, 9] + ] as const)( + 'retires a superseded %s generation without disposing the mux', + async (_generation, clientGeneration, ownerGeneration) => { + const { internals, mux } = prepareSession() + const reattach = vi.fn().mockResolvedValue(true) + internals.reattachRejectedPty = reattach + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await internals.acceptPtyData( + rejectedPayload({ source: source({ clientGeneration, ownerGeneration }) }) + ) + + expect(reattach).toHaveBeenCalledWith('pty-bad', mux, 23, 'confirm-existing') + expect(acceptOutputDataMock).not.toHaveBeenCalled() + expect(mux.dispose).not.toHaveBeenCalled() + } + ) + + it('logs malformed source metadata and reattaches from the exact outer PTY identity', async () => { + const { internals } = prepareSession() + const reattach = vi.fn().mockResolvedValue(true) + internals.reattachRejectedPty = reattach + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await internals.acceptPtyData( + rejectedPayload({ source: undefined, sourceMalformed: true, sourceRejected: undefined }) + ) + + expect(warn).toHaveBeenCalledWith( + '[ssh-relay-session] Rejected PTY delivery', + expect.objectContaining({ + ptyId: 'ssh:target-1@@pty-bad', + providerGeneration: 23, + expectedClientGeneration: 1, + expectedOwnerGeneration: 1, + sourceState: 'source-malformed', + recoveryAction: 'retire-and-reattach-delivery' + }) + ) + expect(reattach).toHaveBeenCalledWith('pty-bad', expect.anything(), 23, 'confirm-existing') + expect(acceptOutputDataMock).not.toHaveBeenCalled() + }) + + it('reattaches only the rejected PTY while a healthy sibling keeps delivering', async () => { + const { deps, internals, mux } = prepareSession() + const provider = {} as SshPtyProvider + getSshPtyProviderMock.mockReturnValue(provider) + vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([ + { + targetId: 'target-1', + ptyId: 'pty-bad', + state: 'detached', + createdAt: 1, + updatedAt: 1 + }, + { + targetId: 'target-1', + ptyId: 'pty-healthy', + state: 'attached', + createdAt: 1, + updatedAt: 1 + } + ]) + const reattachKnownPty = vi.fn( + async (args: Parameters[0]) => { + args.attachedLeaseIds.add(args.ptyId) + } + ) + internals.reattachKnownPty = reattachKnownPty + + await internals.acceptPtyData(rejectedPayload({ rejectedSourceRecovery: 'fresh-activation' })) + await vi.waitFor(() => expect(reattachKnownPty).toHaveBeenCalledOnce()) + await internals.acceptPtyData( + rejectedPayload({ + id: 'ssh:target-1@@pty-healthy', + data: 'healthy', + ptyIncarnation: 'incarnation-healthy', + source: source({ + relayPtyId: 'pty-healthy', + spanId: 'token-healthy:0:7', + deliveryToken: 'token-healthy', + sourceEndSu: 7 + }), + sourceRejected: undefined + }) + ) + + const recovery = reattachKnownPty.mock.calls[0]?.[0] + expect(recovery?.ptyId).toBe('pty-bad') + expect(Array.from(recovery?.activeLeaseByPtyId.keys() ?? [])).toEqual(['pty-bad']) + expect(Array.from(recovery?.expectedIdentityByPtyId.keys() ?? [])).toEqual([]) + expect(recovery?.targetedDeliveryRecovery).toBe('fresh-activation') + expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ + 'pty-bad' + ]) + expect(acceptOutputDataMock).toHaveBeenCalledOnce() + expect(acceptOutputDataMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ssh:target-1@@pty-healthy', data: 'healthy' }) + ) + expect(mux.dispose).not.toHaveBeenCalled() + }) + + it('reconnects instead of canceling an unprovable malformed delivery', async () => { + const { internals, mux } = prepareSession() + const reattach = vi.fn().mockResolvedValue(true) + internals.reattachRejectedPty = reattach + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await internals.acceptPtyData( + rejectedPayload({ + source: undefined, + sourceMalformed: true, + sourceRejected: undefined, + rejectedSourceRecovery: 'reconnect-channel' + }) + ) + + expect(mux.dispose).toHaveBeenCalledWith('connection_lost') + expect(reattach).not.toHaveBeenCalled() + }) + + it('opens a fresh activation after exact rejected-delivery cancellation', async () => { + const { deps, internals, mux } = prepareSession() + internals.sourceIdentityByRelayPtyId.set('pty-bad', { + clientGeneration: 1, + ownerGeneration: 1, + ptyIncarnation: 'incarnation-bad', + deliveryToken: 'token-old', + nextSourceSu: 4 + }) + const commit = vi.fn() + const attachForReconnect = vi.fn(async () => ({ + incarnationId: 'incarnation-bad', + sourceActivation: { + status: 'pending' as const, + clientGeneration: 1, + ownerGeneration: 1, + ptyIncarnation: 'incarnation-bad', + deliveryToken: 'token-fresh', + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }, + sourceActivationLease: { commit, rollback: vi.fn(async () => true) } + })) + getSshPtyProviderMock.mockReturnValue({ attachForReconnect } as unknown as SshPtyProvider) + + await expect( + internals.reattachRejectedPty('pty-bad', mux, 23, 'fresh-activation') + ).resolves.toBe(true) + + expect(attachForReconnect).toHaveBeenCalledWith('pty-bad') + expect(commit).toHaveBeenCalledOnce() + expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ + 'pty-bad' + ]) + await internals.acceptPtyData( + rejectedPayload({ + data: 'fresh', + sourceRejected: undefined, + source: source({ deliveryToken: 'token-fresh' }) + }) + ) + expect(acceptOutputDataMock).toHaveBeenCalledWith(expect.objectContaining({ data: 'fresh' })) + expect(mux.dispose).not.toHaveBeenCalled() + }) + + it('accepts an exact existing activation as stale-frame confirmation', async () => { + const { internals, mux } = prepareSession() + const checkpoint = { + status: 'checkpoint' as const, + clientGeneration: 1, + ownerGeneration: 1, + ptyIncarnation: 'incarnation-bad', + deliveryToken: 'token-current', + acceptedSourceEndSu: 7 + } + internals.sourceRecoveryRequest = vi.fn(async () => checkpoint) + const commit = vi.fn() + const attachForReconnect = vi.fn(async () => ({ + incarnationId: 'incarnation-bad', + sourceActivation: { + status: 'pending' as const, + clientGeneration: 1, + ownerGeneration: 1, + ptyIncarnation: 'incarnation-bad', + deliveryToken: 'token-current', + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }, + sourceActivationLease: { commit, rollback: vi.fn(async () => true) } + })) + getSshPtyProviderMock.mockReturnValue({ attachForReconnect } as unknown as SshPtyProvider) + + await expect( + internals.reattachRejectedPty('pty-bad', mux, 23, 'confirm-existing') + ).resolves.toBe(true) + + expect(attachForReconnect).toHaveBeenCalledWith('pty-bad', undefined, checkpoint) + expect(commit).toHaveBeenCalledOnce() + expect(mux.dispose).not.toHaveBeenCalled() + }) + + it('forgets rejected-delivery recovery history when the PTY exits', () => { + const { internals } = prepareSession() + internals.rejectedPtyRecoveryAttempts.set('ssh:target-1@@pty-bad', {}) + + internals.retireExitedPty({ + id: 'ssh:target-1@@pty-bad', + code: 0, + providerGeneration: 23, + ptyIncarnation: 'incarnation-bad' + }) + + expect(internals.rejectedPtyRecoveryAttempts).toHaveLength(0) + }) + + // Why a channel drop rather than a terminal relay error: a terminal error clears the reconnect + // backoff and rotates provider authority, aborting every fs and git request on the target, so one + // PTY's undeliverable output would strand the whole connection in manual recovery. + it('bounds failed targeted recovery and escalates to a recoverable relay reconnect', async () => { + const { internals, mux, session } = prepareSession() + const reattach = vi.fn().mockResolvedValue(false) + internals.reattachRejectedPty = reattach + getSshPtyProviderMock.mockReturnValue({ hasPty: () => true } as unknown as SshPtyProvider) + const onTerminalError = vi.fn() + session.setOnTerminalRelayError(onTerminalError) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await internals.acceptPtyData(rejectedPayload()) + await vi.waitFor(() => expect(mux.dispose).toHaveBeenCalledOnce(), { timeout: 2000 }) + + expect(reattach).toHaveBeenCalledTimes(2) + expect(reattach.mock.calls).toEqual([ + ['pty-bad', mux, 23, 'confirm-existing'], + ['pty-bad', mux, 23, 'confirm-existing'] + ]) + expect(mux.dispose).toHaveBeenCalledWith('connection_lost') + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('PTY pty-bad delivery recovery exhausted') + ) + expect(onTerminalError).not.toHaveBeenCalled() + expect(acceptOutputDataMock).not.toHaveBeenCalled() + }) + + it('stops without an error when the rejected PTY exited during recovery', async () => { + const { internals, mux, session } = prepareSession() + const reattach = vi.fn().mockResolvedValue(false) + internals.reattachRejectedPty = reattach + // Why: reattachKnownPty resolves without claiming the lease when the PTY exits mid-attach, so a + // plain "not recovered" is indistinguishable from a failure until liveness is checked. + getSshPtyProviderMock.mockReturnValue({ hasPty: () => false } as unknown as SshPtyProvider) + const onTerminalError = vi.fn() + session.setOnTerminalRelayError(onTerminalError) + + await internals.acceptPtyData(rejectedPayload()) + await vi.waitFor(() => expect(reattach).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 400)) + + expect(reattach).toHaveBeenCalledOnce() + expect(onTerminalError).not.toHaveBeenCalled() + expect(mux.dispose).not.toHaveBeenCalled() + }) + + // Why bounded: the bulk reconnect path caps the identical attach work at 8, and a relay that + // starts rejecting across every PTY at once would otherwise open one attach round trip per PTY. + it('caps concurrent targeted reattaches and coalesces repeats for one PTY', async () => { + const { internals } = prepareSession() + getSshPtyProviderMock.mockReturnValue({ hasPty: () => true } as unknown as SshPtyProvider) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const release: (() => void)[] = [] + let active = 0 + let peak = 0 + const reattach = vi.fn(async () => { + active++ + peak = Math.max(peak, active) + await new Promise((resolve) => release.push(resolve)) + active-- + return true + }) + internals.reattachRejectedPty = reattach + + for (let index = 0; index < 20; index++) { + const relayPtyId = `pty-${index}` + await internals.acceptPtyData( + rejectedPayload({ + id: `ssh:target-1@@${relayPtyId}`, + source: source({ relayPtyId, deliveryToken: `token-${index}` }) + }) + ) + // Why twice: a repeat for a PTY already recovering must not consume a second slot. + await internals.acceptPtyData( + rejectedPayload({ + id: `ssh:target-1@@${relayPtyId}`, + source: source({ relayPtyId, deliveryToken: `token-${index}-repeat` }) + }) + ) + } + + expect(peak).toBe(8) + expect(reattach).toHaveBeenCalledTimes(8) + for (const resolve of release.splice(0)) { + resolve() + } + await vi.waitFor(() => expect(reattach).toHaveBeenCalledTimes(16)) + expect(peak).toBe(8) + for (const resolve of release.splice(0)) { + resolve() + } + }) + + it('does not let accepted frames refill the recovery budget indefinitely', async () => { + const { internals, mux, session } = prepareSession() + const reattach = vi.fn().mockResolvedValue(true) + internals.reattachRejectedPty = reattach + getSshPtyProviderMock.mockReturnValue({ hasPty: () => true } as unknown as SshPtyProvider) + const onTerminalError = vi.fn() + session.setOnTerminalRelayError(onTerminalError) + + // Why alternating, with a fresh bad token each round: every rejection retires its own delivery, + // so a flapping PTY only keeps asking for recovery by moving onto new ones, and the accepted + // frame in between is what used to clear the budget outright. Each reattach here succeeds, so + // the consecutive budget is cleared legitimately too — only the per-generation ceiling can stop + // it. + for (let round = 0; round < 40; round++) { + await internals.acceptPtyData( + rejectedPayload({ + source: source({ + spanId: `token-bad-${round}:0:4`, + deliveryToken: `token-bad-${round}`, + clientGeneration: 9 + }) + }) + ) + await internals.acceptPtyData( + rejectedPayload({ + sourceRejected: undefined, + source: source({ + spanId: `token-good:${round * 4}:${round * 4 + 4}`, + deliveryToken: 'token-good', + sourceStartSu: round * 4, + sourceEndSu: round * 4 + 4 + }) + }) + ) + await Promise.resolve() + } + await vi.waitFor(() => expect(mux.dispose).toHaveBeenCalledOnce(), { timeout: 2000 }) + + expect(onTerminalError).not.toHaveBeenCalled() + expect(acceptOutputDataMock).toHaveBeenCalledTimes(40) + expect(reattach.mock.calls.length).toBeLessThanOrEqual(12) + }) +}) diff --git a/src/main/ssh/ssh-relay-session-relay-loss.test.ts b/src/main/ssh/ssh-relay-session-relay-loss.test.ts new file mode 100644 index 00000000000..9e70466ab31 --- /dev/null +++ b/src/main/ssh/ssh-relay-session-relay-loss.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SshRelaySession } from './ssh-relay-session' +import type { SshConnection } from './ssh-connection' +import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' + +// #11953: the grace-time notify is the last thing establish()/reconnect() do +// before latching 'ready', and it can dispose the mux synchronously (writer +// admission cap / throwing transport). Latching 'ready' there left the status +// bar on "connected" with fs/pty/git providers bound to a dead mux and no +// relay-loss watcher, so nothing ever scheduled a reconnect. + +const { muxRequestMock, openConsumerSessionMock, registeredPtyProvider } = vi.hoisted(() => ({ + muxRequestMock: vi.fn(), + openConsumerSessionMock: vi.fn(), + registeredPtyProvider: { dispose: vi.fn(), attachForReconnect: vi.fn() } +})) + +vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) +vi.mock('./ssh-pty-consumer-session', () => ({ + openSshPtyConsumerSession: openConsumerSessionMock +})) +vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ + acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), + acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), + allocateSshPtyProviderGeneration: vi.fn(() => 41), + beginSshPtyOutputGenerationMigration: vi.fn(() => ({ + byPty: new Map(), + completion: Promise.resolve() + })), + closeSshPtyOutputGeneration: vi.fn(), + getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), + applySshPtySourceCancellationProof: vi.fn(() => true), + applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), + installSshPtySourceAckPublisher: vi.fn(() => () => {}), + installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) +})) +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn().mockResolvedValue('') +})) + +// Mirrors the real multiplexer contract: dispose() latches, and a failed write +// during notify disposes the mux synchronously via handleProtocolError. +vi.mock('./ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class MockSshChannelMultiplexer { + private disposed = false + private disposeReason: string | null = null + private disposeHandlers: ((reason: string) => void)[] = [] + /** Set by the test to kill the channel from inside notify(). */ + failNotifyMethod: string | null = null + notify = vi.fn((method: string) => { + if (method === this.failNotifyMethod) { + this.dispose('connection_lost') + } + }) + notifyWithSettlement = vi.fn() + request = muxRequestMock + onNotification = vi.fn().mockReturnValue(() => {}) + onNotificationByMethod = vi.fn().mockReturnValue(() => {}) + onRequest = vi.fn().mockReturnValue(() => {}) + onDispose = vi.fn((handler: (reason: string) => void) => { + if (this.disposed) { + handler(this.disposeReason ?? 'shutdown') + return () => {} + } + this.disposeHandlers.push(handler) + return () => { + const idx = this.disposeHandlers.indexOf(handler) + if (idx !== -1) { + this.disposeHandlers.splice(idx, 1) + } + } + }) + dispose = vi.fn((reason = 'shutdown') => { + if (this.disposed) { + return + } + this.disposed = true + this.disposeReason = reason + for (const handler of this.disposeHandlers.splice(0)) { + handler(reason) + } + }) + isDisposed = vi.fn(() => this.disposed) + } +})) + +vi.mock('../providers/ssh-pty-provider', () => ({ + isSshPtyNotFoundError: () => false, + isSshPtyIdentityMismatchError: () => false, + SshPtyProvider: class MockSshPtyProvider { + onData = vi.fn().mockReturnValue(() => {}) + onReplay = vi.fn().mockReturnValue(() => {}) + onExit = vi.fn().mockReturnValue(() => {}) + attach = vi.fn().mockResolvedValue(undefined) + attachForReconnect = vi.fn().mockResolvedValue({}) + setPtyDeliveryPauseAdapter = vi.fn() + dispose = vi.fn() + } +})) +vi.mock('../providers/ssh-filesystem-provider', () => ({ + SshFilesystemProvider: class MockSshFilesystemProvider { + dispose = vi.fn() + } +})) +vi.mock('../providers/ssh-git-provider', () => ({ + SshGitProvider: class MockSshGitProvider {} +})) +vi.mock('../ipc/pty', () => ({ + registerSshPtyProvider: vi.fn(), + unregisterSshPtyProvider: vi.fn(), + getSshPtyProvider: vi.fn().mockReturnValue(registeredPtyProvider), + getPtyIdsForConnection: vi.fn().mockReturnValue([]), + clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn(), + setPtyOwnership: vi.fn(), + restorePtyIncarnation: vi.fn(), + isCurrentPtyExit: vi.fn(() => true) +})) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + registerSshFilesystemProvider: vi.fn(), + unregisterSshFilesystemProvider: vi.fn(), + getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + registerSshGitProvider: vi.fn(), + unregisterSshGitProvider: vi.fn() +})) + +const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = + await import('../providers/ssh-filesystem-dispatch') +const { getPtyIdsForConnection } = await import('../ipc/pty') + +describe('SshRelaySession relay loss during setup', () => { + /** Armed by a test so the *next* mux dies inside its grace-time notify. */ + let armGraceTimeFailure = false + let armProviderRegistrationFailure = false + + beforeEach(() => { + vi.clearAllMocks() + armGraceTimeFailure = false + armProviderRegistrationFailure = false + muxRequestMock.mockReset() + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + registeredPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) + openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ + mode: 'legacy-fallback', + clientInstanceId: options.clientInstanceId, + serverBuildId: 'test-relay-build' + })) + mockDeploySuccess() + }) + + function createSession(): { + session: SshRelaySession + onRelayLost: ReturnType + onReady: ReturnType + mockStore: ReturnType['mockStore'] + } { + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + const onRelayLost = vi.fn() + const onReady = vi.fn() + session.setOnRelayLost(onRelayLost) + session.setOnReady(onReady) + // Arm once the mux exists; every attempt issues requests before the notify. + muxRequestMock.mockImplementation(async (method: string) => { + const mux = session.getMux() as unknown as { + failNotifyMethod: string | null + dispose: (reason: string) => void + } | null + if (mux && armGraceTimeFailure) { + mux.failNotifyMethod = SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD + } + if (mux && armProviderRegistrationFailure && method === 'git.listWorktrees') { + mux.dispose('connection_lost') + throw new Error('SSH connection lost, reconnecting...') + } + return [] + }) + return { session, onRelayLost, onReady, mockStore } + } + + it('fails establish instead of reporting ready on a dead channel', async () => { + const { session, onReady } = createSession() + armGraceTimeFailure = true + + await expect(session.establish({} as SshConnection)).rejects.toThrow( + 'Relay connection lost during establish' + ) + + expect(registerSshFilesystemProvider).toHaveBeenCalledWith('target-1', expect.anything()) + expect(session.getState()).not.toBe('ready') + expect(onReady).not.toHaveBeenCalled() + expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') + }) + + it('routes a dead channel during reconnect into relay-loss recovery', async () => { + const { session, onRelayLost, onReady } = createSession() + + await session.establish({} as SshConnection) + expect(session.getState()).toBe('ready') + onReady.mockClear() + + armGraceTimeFailure = true + await session.reconnect({} as SshConnection) + + expect(session.getState()).not.toBe('ready') + expect(onReady).not.toHaveBeenCalled() + expect(onRelayLost).toHaveBeenCalledTimes(1) + expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') + }) + + it('routes a mux that dies during provider registration into relay-loss recovery', async () => { + const { session, onRelayLost, onReady, mockStore } = createSession() + + await session.establish({} as SshConnection) + expect(session.getState()).toBe('ready') + onReady.mockClear() + + vi.mocked(mockStore.getRepos).mockReturnValue([ + { connectionId: 'target-1', path: '/repo' } as ReturnType[number] + ]) + armProviderRegistrationFailure = true + + await session.reconnect({} as SshConnection) + + expect(session.getState()).not.toBe('ready') + expect(onReady).not.toHaveBeenCalled() + expect(onRelayLost).toHaveBeenCalledTimes(1) + expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') + }) + + // #11953: reattachKnownPtys swallows every per-PTY failure, so a mux killed by the + // reattach burst itself never reaches the catch — the post-reattach gate has to notice. + it('routes a mux that dies during PTY reattach into relay-loss recovery', async () => { + const { session, onRelayLost, onReady } = createSession() + + await session.establish({} as SshConnection) + expect(session.getState()).toBe('ready') + onReady.mockClear() + + vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) + registeredPtyProvider.attachForReconnect.mockImplementation(async () => { + const mux = session.getMux() as unknown as { dispose: (reason: string) => void } | null + mux?.dispose('connection_lost') + throw new Error('SSH connection lost, reconnecting...') + }) + + await session.reconnect({} as SshConnection) + + expect(registeredPtyProvider.attachForReconnect).toHaveBeenCalled() + expect(session.getState()).not.toBe('ready') + expect(onReady).not.toHaveBeenCalled() + expect(onRelayLost).toHaveBeenCalledTimes(1) + expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') + }) +}) diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index b0ed98eaf2e..48461af68ca 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -1,6 +1,6 @@ import { vi, type Mock } from 'vitest' import type { BrowserWindow } from 'electron' -import { PtyConsumerSession } from '../../shared/pty-consumer-session' +import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session' import type { SshConnection } from './ssh-connection' import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' @@ -25,6 +25,7 @@ export function createMockDeps(): SshRelaySessionTestDeps { markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), markSshRemotePtyLeasesAsync: vi.fn(), + markSshRemotePtyLeasesForShutdown: vi.fn(), markSshRemotePtyLeasesAttachedAsync: vi.fn(), persistPtyBinding: vi.fn() } as unknown as Store @@ -55,37 +56,7 @@ export function mockDeploySuccess(): void { } export function createMismatchedOwnerRecoveryError(): unknown { - const stateMachine = new PtyConsumerSession({ - serverBuildId: 'test-relay-build', - createLease: () => 'retained-owner-lease' + return Object.assign(new Error('Owner recovery lease is stale'), { + code: PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR }) - const owner = stateMachine.admit( - { clientInstanceId: 'retained-client', requestedRole: 'session-owner' }, - { - connectionId: 'retained-connection', - principal: 'retained-principal', - authenticated: true, - allowSessionOwner: true - } - ) - owner.commitPublication() - stateMachine.close('retained-connection') - try { - stateMachine.admit( - { - clientInstanceId: 'retained-client', - requestedRole: 'session-owner', - resume: { ownerGeneration: 1, ownerLease: 'retained-owner-lease' } - }, - { - connectionId: 'stale-connection', - principal: 'stale-principal', - authenticated: true, - allowSessionOwner: true - } - ) - } catch (error) { - return error - } - throw new Error('Expected mismatched owner recovery to fail') } diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 34bdf498843..f44d2a77dbd 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -6,7 +6,6 @@ import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand } from './ssh-relay-deploy-helpers' import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' -import type { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { SshPtyProvider } from '../providers/ssh-pty-provider' import type { SshPtyAttachResult } from '../providers/ssh-pty-session-reattach' @@ -81,7 +80,14 @@ import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session' -import { retrySshOwnerRecoveryWhileBlocked } from './ssh-owner-recovery-retry' +import { + isSshOwnerAdmissionBlocked, + retrySshOwnerRecoveryWhileBlocked +} from './ssh-owner-recovery-retry' +import { + isSshOwnerAdmissionBlockedError, + SshOwnerAdmissionBlockedError +} from './ssh-owner-admission-blocked-error' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' import { acknowledgeRemoteOrcaCliPostOutput, @@ -97,6 +103,8 @@ import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { openSshPtyConsumerSession, + type OpenSshPtyConsumerSessionOptions, + type SshPtyConsumerAdmission, type SshPtyConsumerOwnerState, type SshPtyConsumerSessionState } from './ssh-pty-consumer-session' @@ -112,9 +120,10 @@ import { detachSshPtyConsumerRecovery, forgetSshPtyConsumerRecovery, getSshPtyConsumerRecovery, - rememberSshPtyConsumerRecovery, - removeSshPtyConsumerOwnerRecovery + rememberSshPtyConsumerRecovery } from './ssh-pty-consumer-recovery' +import { classifySshPtyFrameRejection, SshPtyFrameRejectionLog } from './ssh-pty-frame-rejection' +import { SshPtyTargetedReattachQueue } from './ssh-pty-targeted-reattach-queue' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -125,7 +134,29 @@ const SSH_PTY_REATTACH_MAX_CONCURRENCY = 8 const SSH_PTY_REATTACH_ATTEMPT_TIMEOUT_MS = 10_000 const SSH_PTY_REATTACH_RETRY_MIN_DELAY_MS = 50 const SSH_PTY_REATTACH_RETRY_JITTER_MS = 200 +const SSH_REJECTED_PTY_RECOVERY_MAX_ATTEMPTS = 2 +// Why a second ceiling: the consecutive budget resets whenever a reattach succeeds, so a PTY that +// alternates recovered and rejected frames would otherwise reattach forever — each one costs a +// store read, an attach round trip and a store write. +const SSH_REJECTED_PTY_RECOVERY_MAX_GENERATION_ATTEMPTS = 12 +const SSH_REJECTED_PTY_RECOVERY_RETRY_DELAY_MS = 150 const SSH_SOURCE_RECOVERY_CANCELLATION_FAILED = 'ssh_source_recovery_cancellation_failed' + +// Why: superseded attempts stop quietly; a dead mux still owned by this attempt must enter recovery. +function verifyRelayAttempt( + mux: SshChannelMultiplexer, + isAttemptCurrent: () => boolean, + phase: string +): boolean { + if (!isAttemptCurrent()) { + return false + } + if (mux.isDisposed()) { + throw new Error(`Relay connection lost during ${phase}`) + } + return true +} + type PendingPtyReattach = { mux: SshChannelMultiplexer providerGeneration: number @@ -157,6 +188,7 @@ type RemoteCliBridgeEnv = { } type ExpectedPtyIdentity = { paneKey?: string; tabId?: string } +type TargetedDeliveryRecovery = 'confirm-existing' | 'fresh-activation' function expectedIdentityForLease(lease: { tabId?: string @@ -270,10 +302,9 @@ export class SshRelaySession { private muxNotificationCleanup: (() => void) | null = null // Why: onStateChange never fires when the relay channel closes but SSH stays up; this callback lets ssh.ts drive relay-level reconnect. private _onRelayLost: ((targetId: string) => void) | null = null - // Why: version mismatch is terminal, so it needs a separate callback from _onRelayLost (which expects a recoverable transport drop). - private _onTerminalRelayError: - | ((targetId: string, err: RelayVersionMismatchError) => void) - | null = null + // Why: a version mismatch or a blocked owner admission is terminal, so it needs a separate callback + // from _onRelayLost (which expects a recoverable transport drop). + private _onTerminalRelayError: ((targetId: string, err: Error) => void) | null = null private _onReady: ((targetId: string) => void) | null = null private portScanner: PortScanner | null = null private currentConnection: SshConnection | null = null @@ -303,6 +334,20 @@ export class SshRelaySession { }> >() private readonly retiredSourceDeliveries = new SshPtyRetiredSourceDeliveries() + private readonly rejectedPtyRecoveryAttempts = new Map< + string, + { + providerGeneration: number + attempts: number + generationAttempts: number + reported: boolean + } + >() + private readonly rejectedPtyRecoveryRetries = new Set>() + private readonly rejectedPtyReattaches = new SshPtyTargetedReattachQueue( + SSH_PTY_REATTACH_MAX_CONCURRENCY + ) + private readonly ptyFrameRejectionLog = new SshPtyFrameRejectionLog() private readonly ptyConsumerClientInstanceId: string private ptyConsumerSessionState: SshPtyConsumerSessionState | null = null private activeCompatibilityAttachmentIds = new Set() @@ -340,7 +385,7 @@ export class SshRelaySession { this._onRelayLost = cb } - setOnTerminalRelayError(cb: (targetId: string, err: RelayVersionMismatchError) => void): void { + setOnTerminalRelayError(cb: (targetId: string, err: Error) => void): void { this._onTerminalRelayError = cb } @@ -468,14 +513,15 @@ export class SshRelaySession { const mux = new SshChannelMultiplexer(transport) this.mux = mux - const ownsAttempt = (): boolean => this.mux === mux && !mux.isDisposed() && !this.isDisposed() + const isAttemptCurrent = (): boolean => this.mux === mux && !this.isDisposed() + const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed() const ptyConsumerSessionState = await this.openPtyConsumerSession( mux, serverBuildId, - ownsAttempt + shouldContinue ) - if (!ownsAttempt()) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer session setup')) { if (!mux.isDisposed()) { mux.dispose() } @@ -483,7 +529,7 @@ export class SshRelaySession { } this.ptyConsumerSessionState = ptyConsumerSessionState await this.rememberPtyConsumerRecovery(serverBuildId) - if (!ownsAttempt()) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { if (!mux.isDisposed()) { mux.dispose() } @@ -491,7 +537,7 @@ export class SshRelaySession { } await mux.request('session.resolveHome', { path: '~' }) - if (!ownsAttempt()) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'home resolution')) { if (!mux.isDisposed()) { mux.dispose() } @@ -499,33 +545,35 @@ export class SshRelaySession { } const connectionIncarnation = randomUUID() - const registered = await this.registerProviders(mux, ownsAttempt, connectionIncarnation) + const registered = await this.registerProviders(mux, shouldContinue, connectionIncarnation) if (!registered) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { + if (!mux.isDisposed()) { + mux.dispose() + } + throw new Error('Session disposed during establish') + } + throw new Error('Relay provider registration stopped unexpectedly') + } + + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { if (!mux.isDisposed()) { mux.dispose() } throw new Error('Session disposed during establish') } - // Why: registerProviders swallows mux errors, so an isDisposed check catches a transport that closed mid-registration before we reach 'ready'. - if (mux.isDisposed()) { - throw new Error('Relay connection lost during provider registration') - } - - if (this.isDisposed()) { - this.teardownProviders('connection_lost') - throw new Error('Session disposed during establish') - } - // Why: explicit disconnect keeps PTY ownership, so a later manual connect must reattach those remote PTYs. - await this.reattachKnownPtys(mux, ownsAttempt) + await this.reattachKnownPtys(mux, shouldContinue) - if (!ownsAttempt()) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'PTY reattach')) { throw new Error('Session disposed during establish') } this.configureRelayGraceTime(mux, graceTimeSeconds) + verifyRelayAttempt(mux, isAttemptCurrent, 'establish') this.watchMuxForRelayLoss(mux) + verifyRelayAttempt(mux, isAttemptCurrent, 'establish') this._state = 'ready' this.startPortScanning() this._onReady?.(this.targetId) @@ -540,10 +588,11 @@ export class SshRelaySession { ) this._state = 'idle' } - // Why: a version mismatch on first connect is terminal (deployed binary vs. a still-running legacy daemon); notify the callback but still rethrow. - if (isRelayVersionMismatchError(err)) { + // Why: terminal on first connect — a deployed binary against a still-running legacy daemon, or a + // claim another connection holds. Notify the callback but still rethrow. + if (isRelayVersionMismatchError(err) || isSshOwnerAdmissionBlockedError(err)) { console.warn( - `[ssh-relay-session] Terminal relay version mismatch on initial connect for ${this.targetId}: ${err.message}` + `[ssh-relay-session] Terminal relay error on initial connect for ${this.targetId}: ${err.message}` ) this._onTerminalRelayError?.(this.targetId, err) } @@ -609,19 +658,19 @@ export class SshRelaySession { const mux = new SshChannelMultiplexer(transport) this.mux = mux - const ownsAttempt = (): boolean => + const isAttemptCurrent = (): boolean => this.mux === mux && - !mux.isDisposed() && this.abortController === abortController && !abortController.signal.aborted && !this.isDisposed() + const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed() const ptyConsumerSessionState = await this.openPtyConsumerSession( mux, serverBuildId, - ownsAttempt + shouldContinue ) - if (!ownsAttempt()) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer session setup')) { if (!mux.isDisposed()) { mux.dispose() } @@ -629,7 +678,7 @@ export class SshRelaySession { } this.ptyConsumerSessionState = ptyConsumerSessionState await this.rememberPtyConsumerRecovery(serverBuildId) - if (!ownsAttempt()) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { if (!mux.isDisposed()) { mux.dispose() } @@ -637,7 +686,7 @@ export class SshRelaySession { } await mux.request('session.resolveHome', { path: '~' }) - if (!ownsAttempt()) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'home resolution')) { if (!mux.isDisposed()) { mux.dispose() } @@ -645,20 +694,21 @@ export class SshRelaySession { } const connectionIncarnation = randomUUID() - const registered = await this.registerProviders(mux, ownsAttempt, connectionIncarnation) + const registered = await this.registerProviders(mux, shouldContinue, connectionIncarnation) if (!registered) { - if (!mux.isDisposed()) { - mux.dispose() + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { + if (this.mux === mux) { + this.teardownProviders('shutdown') + } else if (!mux.isDisposed()) { + mux.dispose() + } + return } - return - } - - if (mux.isDisposed()) { - throw new Error('Relay connection lost during provider registration') + throw new Error('Relay provider registration stopped unexpectedly') } // Why: dispose() during registration/attach already cleaned up, but this.mux was reassigned above — clean up the new mux so it doesn't leak. - if (!ownsAttempt()) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { if (this.mux === mux) { this.teardownProviders('shutdown') } else if (!mux.isDisposed()) { @@ -667,14 +717,16 @@ export class SshRelaySession { return } - await this.reattachKnownPtys(mux, ownsAttempt) + await this.reattachKnownPtys(mux, shouldContinue) - if (!ownsAttempt()) { + if (!verifyRelayAttempt(mux, isAttemptCurrent, 'PTY reattach')) { return } this.configureRelayGraceTime(mux, graceTimeSeconds) + verifyRelayAttempt(mux, isAttemptCurrent, 'reconnect') this.watchMuxForRelayLoss(mux) + verifyRelayAttempt(mux, isAttemptCurrent, 'reconnect') this._state = 'ready' this.startPortScanning() this._onReady?.(this.targetId) @@ -688,10 +740,11 @@ export class SshRelaySession { : 'connection_lost' ) } - // Why: version-mismatch is terminal — fire the typed callback and drop out of 'reconnecting' since backoff retry can't reconcile it. - if (isRelayVersionMismatchError(err)) { + // Why terminal: neither a version mismatch nor a blocked owner claim is reconcilable by backoff + // retry, so fire the typed callback and drop out of 'reconnecting'. + if (isRelayVersionMismatchError(err) || isSshOwnerAdmissionBlockedError(err)) { console.warn( - `[ssh-relay-session] Terminal relay version mismatch for ${this.targetId}: ${err.message}` + `[ssh-relay-session] Terminal relay error for ${this.targetId}: ${err.message}` ) if (this.abortController === abortController && !this.isDisposed()) { this._state = 'idle' @@ -809,6 +862,28 @@ export class SshRelaySession { } } + // Why a separate transition from detachAndPersist: on the committed quit path every in-memory + // change has to land before the final store flush snapshots, and that flush — not a per-session + // durable write — is what persists it. Idempotent, and it schedules no persistence of its own, so + // nothing the async drain finishes later can write recovery state after the snapshot. + // + // 'detached' says this app let go of the lease, not that the remote shell died. The remote PTYs are + // left running for the next attach, exactly as an ordinary detach leaves them. + beginShutdownDetach(): void { + if (this.detachedInMemory || this._state === 'disposed') { + return + } + detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) + this.abortController?.abort() + this.stopPortScanning() + this.broadcastEmptyLists() + this.teardownProviders('connection_lost') + this.currentConnection = null + this._state = 'disposed' + this.detachedInMemory = true + this.store.markSshRemotePtyLeasesForShutdown(this.targetId, 'detached') + } + private runDetach(): Promise { if (!this.detachedInMemory) { if (this._state === 'disposed') { @@ -1021,12 +1096,40 @@ export class SshRelaySession { ownsAttempt: () => boolean ): Promise { const previousOwner = this.recoverablePtyConsumerOwner(serverBuildId) - const options = { + const options: OpenSshPtyConsumerSessionOptions = { clientInstanceId: this.ptyConsumerClientInstanceId, expectedServerBuildId: serverBuildId, allowSameBuildLegacyFallback: true, outputFlowControl: { requestedWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } } + let admission: SshPtyConsumerAdmission + try { + admission = await this.admitPtyConsumerOwner(mux, previousOwner, options, ownsAttempt) + } catch (error) { + if ( + !previousOwner || + (error as { code?: unknown }).code !== PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR + ) { + throw error + } + this.voidPtyConsumerCheckpoints(previousOwner, ownsAttempt) + if (!ownsAttempt()) { + throw new Error('Session disposed during owner recovery') + } + admission = await openSshPtyConsumerSession(mux, options) + } + if (previousOwner && !admission.resumed) { + this.voidPtyConsumerCheckpoints(previousOwner, ownsAttempt) + } + return admission.state + } + + private async admitPtyConsumerOwner( + mux: SshChannelMultiplexer, + previousOwner: SshPtyConsumerOwnerState | null, + options: OpenSshPtyConsumerSessionOptions, + ownsAttempt: () => boolean + ): Promise { try { return await retrySshOwnerRecoveryWhileBlocked( () => @@ -1047,48 +1150,47 @@ export class SshRelaySession { } ) } catch (error) { - if ( - !previousOwner || - (error as { code?: unknown }).code !== PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR - ) { - throw error + // Why converted here: past this point the failure travels the same path as a dropped transport, + // where backoff would keep redeploying a relay that is working fine and refusing on purpose. + if (isSshOwnerAdmissionBlocked(error)) { + throw new SshOwnerAdmissionBlockedError(this.targetId, { cause: error }) } - const recovery = getSshPtyConsumerRecovery(this.targetId) - // Why identity-guarded: the record is target-scoped and its clientInstanceId is shared by every - // session for that target, so only a record still describing the owner this attempt tried to - // resume is ours to drop — otherwise a loser wipes the winner's checkpoints. - const ownsRecoveryRecord = - ownsAttempt() && - (!recovery?.owner || - (recovery.owner.ownerGeneration === previousOwner.ownerGeneration && - recovery.owner.ownerLease === previousOwner.ownerLease)) - if (ownsRecoveryRecord) { - if (recovery) { - delete recovery.owner - recovery.checkpointsByAppPtyId.clear() - for (const [ptyId, migration] of recovery.modelMigrationsByAppPtyId) { - recovery.modelMigrationsByAppPtyId.set( - ptyId, - migration.then(() => - Object.freeze({ - status: 'checkpoint-unavailable' as const, - reason: 'completion-failed' as const - }) - ) - ) - } - } - await removeSshPtyConsumerOwnerRecovery( - this.targetId, - this.ptyConsumerClientInstanceId, - this.store + throw error + } + } + + // Why the recovery row and clientInstanceId survive: the relay minted a fresh claim, which voids the + // checkpoints taken under the old one but says nothing about our identity for this target. The caller + // durably records the new lease before ready, so removal here would only lose the identity. + private voidPtyConsumerCheckpoints( + previousOwner: SshPtyConsumerOwnerState, + ownsAttempt: () => boolean + ): void { + const recovery = getSshPtyConsumerRecovery(this.targetId) + // Why identity-guarded: the record is target-scoped and its clientInstanceId is shared by every + // session for that target, so only a record still describing the owner this attempt tried to + // resume is ours to void — otherwise a loser wipes the winner's checkpoints. + if ( + !recovery || + !ownsAttempt() || + (recovery.owner && + (recovery.owner.ownerGeneration !== previousOwner.ownerGeneration || + recovery.owner.ownerLease !== previousOwner.ownerLease)) + ) { + return + } + delete recovery.owner + recovery.checkpointsByAppPtyId.clear() + for (const [ptyId, migration] of recovery.modelMigrationsByAppPtyId) { + recovery.modelMigrationsByAppPtyId.set( + ptyId, + migration.then(() => + Object.freeze({ + status: 'checkpoint-unavailable' as const, + reason: 'completion-failed' as const + }) ) - } - if (!ownsAttempt()) { - throw new Error('Session disposed during establish') - } - this.ptyConsumerSessionState = null - return openSshPtyConsumerSession(mux, options) + ) } } @@ -1472,6 +1574,13 @@ export class SshRelaySession { unregisterSshGitProvider(this.targetId) this.sourceIdentityByRelayPtyId.clear() this.retiredSourceDeliveries.clear() + this.rejectedPtyRecoveryAttempts.clear() + for (const timer of this.rejectedPtyRecoveryRetries) { + clearTimeout(timer) + } + this.rejectedPtyRecoveryRetries.clear() + this.rejectedPtyReattaches.clear() + this.ptyFrameRejectionLog.clear() for (const pending of this.pendingPtyReattaches.values()) { for (const resolve of pending.recoveryWaiters) { resolve() @@ -1574,6 +1683,24 @@ export class SshRelaySession { } void this.acceptPtyData(payload).catch(() => {}) }) + ptyProvider.onRejectedData?.((payload) => { + if ( + this.mux !== mux || + this.activePtyProviderGeneration !== providerGeneration || + payload.providerGeneration !== providerGeneration + ) { + return + } + const pending = this.pendingPtyReattaches.get(payload.id) + if (pending) { + pending.restoreRequired = payload.sourceMalformed + ? 'recoverySourceMalformed' + : 'recoverySourceUnadmitted' + this.wakeRecovery(pending) + return + } + void this.acceptPtyData(payload).catch(() => {}) + }) ptyProvider.onReplay((payload) => { if (this.mux !== mux || this.activePtyProviderGeneration !== providerGeneration) { return @@ -1614,22 +1741,19 @@ export class SshRelaySession { ) { return Promise.resolve() } - if ( - consumerOwner?.outputFlowControl && - (!offeredSource || - payload.sourceMalformed || - offeredSource.clientGeneration !== consumerOwner.clientGeneration || - offeredSource.ownerGeneration !== consumerOwner.ownerGeneration) - ) { - closeSshPtyOutputGeneration( - payload.providerGeneration, - 'ssh_source_frame_malformed_or_missing' - ) - this.mux?.dispose('connection_lost') - return Promise.reject(new Error('ssh_source_frame_malformed_or_missing')) + const rejection = classifySshPtyFrameRejection(payload, consumerOwner) + if (rejection) { + if (offeredSource) { + this.retiredSourceDeliveries.retire(payload.providerGeneration, offeredSource) + } + this.ptyFrameRejectionLog.record(payload, consumerOwner, rejection) + if (rejection.action === 'retire-and-reattach-delivery') { + this.recoverRejectedPtyDelivery(payload, offeredSource) + } + return Promise.resolve() } const source = consumerOwner?.outputFlowControl ? offeredSource : undefined - if (source) { + if (source && consumerOwner) { const current = this.sourceIdentityByRelayPtyId.get(source.relayPtyId) if ( source.sourceEndSu <= source.sourceStartSu || @@ -1640,12 +1764,14 @@ export class SshRelaySession { current.ptyIncarnation !== payload.ptyIncarnation || (current.nextSourceSu !== undefined && current.nextSourceSu !== source.sourceStartSu))) ) { - closeSshPtyOutputGeneration( - payload.providerGeneration, - 'ssh_source_frame_stale_or_non_contiguous' - ) - this.mux?.dispose('connection_lost') - return Promise.reject(new Error('ssh_source_frame_stale_or_non_contiguous')) + const rejection = { + reason: 'source-range-invalid', + action: 'retire-and-reattach-delivery' + } as const + this.retiredSourceDeliveries.retire(payload.providerGeneration, source) + this.ptyFrameRejectionLog.record(payload, consumerOwner, rejection) + this.recoverRejectedPtyDelivery(payload, source) + return Promise.resolve() } this.sourceIdentityByRelayPtyId.set(source.relayPtyId, { deliveryToken: source.deliveryToken, @@ -1668,6 +1794,163 @@ export class SshRelaySession { }) } + private recoverRejectedPtyDelivery( + payload: SshPtyDataPayload, + source: SshPtyDataPayload['source'] + ): void { + const mux = this.mux + const providerGeneration = this.activePtyProviderGeneration + let relayPtyId: string + try { + relayPtyId = source?.relayPtyId ?? toRelaySshPtyId(this.targetId, payload.id) + } catch { + return + } + const appPtyId = toAppSshPtyId(this.targetId, relayPtyId) + if ( + payload.id !== appPtyId || + !mux || + mux.isDisposed() || + providerGeneration !== payload.providerGeneration || + this.pendingPtyReattaches.has(appPtyId) || + this.rejectedPtyReattaches.has(appPtyId) + ) { + return + } + if (payload.rejectedSourceRecovery === 'reconnect-channel') { + console.warn( + `[ssh-relay-session] PTY ${relayPtyId} delivery identity could not be retired safely for ${this.targetId}; dropping the relay channel to reconnect` + ) + mux.dispose('connection_lost') + return + } + const previous = this.rejectedPtyRecoveryAttempts.get(appPtyId) + const attempt = + previous?.providerGeneration === providerGeneration + ? previous + : { providerGeneration, attempts: 0, generationAttempts: 0, reported: false } + if ( + attempt.attempts >= SSH_REJECTED_PTY_RECOVERY_MAX_ATTEMPTS || + attempt.generationAttempts >= SSH_REJECTED_PTY_RECOVERY_MAX_GENERATION_ATTEMPTS + ) { + if (!attempt.reported) { + attempt.reported = true + console.warn( + `[ssh-relay-session] PTY ${relayPtyId} delivery recovery exhausted for ${this.targetId}; dropping the relay channel to reconnect` + ) + // Why a channel drop and not a terminal relay error: a terminal error clears the reconnect + // backoff, rotates provider authority (aborting every in-flight fs and git request on the + // target) and parks the target in a manual-recovery state — over one PTY's delivery. Losing + // the channel is the recoverable escalation, and it is what this path did before targeted + // recovery existed. + mux.dispose('connection_lost') + } + return + } + attempt.attempts++ + attempt.generationAttempts++ + this.rejectedPtyRecoveryAttempts.set(appPtyId, attempt) + void this.rejectedPtyReattaches + .run(appPtyId, () => + this.reattachRejectedPty( + relayPtyId, + mux, + providerGeneration, + payload.rejectedSourceRecovery === 'fresh-activation' + ? 'fresh-activation' + : 'confirm-existing' + ) + ) + .then( + (recovered) => { + if (recovered) { + // Why only a completed reattach clears this: an accepted frame proves nothing about the + // delivery that was rejected, and resetting on one lets a flapping PTY reattach forever. + attempt.attempts = 0 + return + } + this.retryRejectedPtyDelivery(payload, source, appPtyId) + }, + (error: unknown) => { + console.warn(`[ssh-relay-session] PTY ${relayPtyId} targeted delivery recovery failed`, { + providerGeneration, + error: error instanceof Error ? error.message : String(error) + }) + this.retryRejectedPtyDelivery(payload, source, appPtyId) + } + ) + } + + // Why liveness is checked before retrying: reattachKnownPty resolves without claiming the lease + // when the PTY exited mid-attach, which is indistinguishable from a failed reattach at the call + // site. Retrying that race twice would drop the relay channel over an ordinary PTY exit. + private retryRejectedPtyDelivery( + payload: SshPtyDataPayload, + source: SshPtyDataPayload['source'], + appPtyId: string + ): void { + const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined + if (!ptyProvider || typeof ptyProvider.hasPty !== 'function' || !ptyProvider.hasPty(appPtyId)) { + this.rejectedPtyRecoveryAttempts.delete(appPtyId) + return + } + const timer = setTimeout(() => { + this.rejectedPtyRecoveryRetries.delete(timer) + this.recoverRejectedPtyDelivery(payload, source) + }, SSH_REJECTED_PTY_RECOVERY_RETRY_DELAY_MS) + timer.unref?.() + this.rejectedPtyRecoveryRetries.add(timer) + } + + private async reattachRejectedPty( + relayPtyId: string, + mux: SshChannelMultiplexer, + providerGeneration: number, + targetedDeliveryRecovery: TargetedDeliveryRecovery + ): Promise { + const shouldContinue = () => + this.mux === mux && + !mux.isDisposed() && + this.activePtyProviderGeneration === providerGeneration + const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined + // Why re-checked here: this can have waited for a queue slot, and a superseded generation must + // not pay for a lease read or an attach round trip. + if (!ptyProvider || !shouldContinue()) { + return false + } + const activeLease = this.store + .getSshRemotePtyLeases(this.targetId) + .find( + (lease) => + lease.ptyId === relayPtyId && lease.state !== 'terminated' && lease.state !== 'expired' + ) + const activeLeaseByPtyId = activeLease + ? new Map([[relayPtyId, activeLease]]) + : new Map() + const expectedIdentity = activeLease ? expectedIdentityForLease(activeLease) : undefined + const attachedLeaseIds = new Set() + await this.reattachKnownPty({ + ptyProvider, + ptyId: relayPtyId, + activeLeaseByPtyId, + expectedIdentityByPtyId: expectedIdentity + ? new Map([[relayPtyId, expectedIdentity]]) + : new Map(), + attachedLeaseIds, + mux, + providerGeneration, + shouldContinue, + targetedDeliveryRecovery + }) + if (attachedLeaseIds.size > 0 && shouldContinue()) { + await this.store.markSshRemotePtyLeasesAttachedAsync( + this.targetId, + Array.from(attachedLeaseIds) + ) + } + return attachedLeaseIds.has(relayPtyId) + } + private quarantineReattachData(pending: PendingPtyReattach, payload: SshPtyDataPayload): void { this.observePrivateRecoveryFrame(pending, payload) if (pending.restoreRequired) { @@ -1880,6 +2163,7 @@ export class SshRelaySession { this.retiredSourceDeliveries.activate(relayPtyId) clearProviderPtyState(payload.id) deletePtyOwnership(payload.id) + this.rejectedPtyRecoveryAttempts.delete(payload.id) getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(payload.id) getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete( toRelaySshPtyId(this.targetId, payload.id) @@ -1988,6 +2272,7 @@ export class SshRelaySession { mux: SshChannelMultiplexer providerGeneration: number shouldContinue: () => boolean + targetedDeliveryRecovery?: TargetedDeliveryRecovery }): Promise { const { ptyProvider, @@ -1997,7 +2282,8 @@ export class SshRelaySession { attachedLeaseIds, mux, providerGeneration, - shouldContinue + shouldContinue, + targetedDeliveryRecovery } = args const appPtyId = toAppSshPtyId(this.targetId, ptyId) const pendingReattach: PendingPtyReattach = { @@ -2016,7 +2302,10 @@ export class SshRelaySession { let sourceActivationLease: SshPtyAttachResult['sourceActivationLease'] let recoveryActivationLease: SshPtyRecoveryActivationLease | undefined try { - const recoveryRequest = await this.sourceRecoveryRequest(appPtyId) + const recoveryRequest = + targetedDeliveryRecovery === 'fresh-activation' + ? undefined + : await this.sourceRecoveryRequest(appPtyId) const attachResult = await this.attachPtyWithRetry( ptyProvider, ptyId, @@ -2042,7 +2331,27 @@ export class SshRelaySession { await this.acceptPtyExit(exitDuringAttach) return } - if (recoveryRequest) { + const existingDeliveryConfirmed = + targetedDeliveryRecovery === 'confirm-existing' && + recoveryRequest?.status === 'checkpoint' && + !attachResult.sourceRecovery && + Boolean( + attachResult.sourceActivation && + this.sameSourceDelivery(attachResult.sourceActivation, recoveryRequest) + ) + if (targetedDeliveryRecovery) { + const owner = this.activePtyConsumerOwner() + const activation = attachResult.sourceActivation + if ( + !owner?.outputFlowControl || + !activation || + activation.clientGeneration !== owner.clientGeneration || + activation.ownerGeneration !== owner.ownerGeneration + ) { + return + } + } + if (recoveryRequest && !existingDeliveryConfirmed) { const recovered = await this.finishSourceRecovery( ptyId, appPtyId, @@ -2114,6 +2423,18 @@ export class SshRelaySession { pendingReattach.activated = true recoveryActivationLease?.commit() recoveryActivationLease = undefined + if (targetedDeliveryRecovery) { + if (targetedDeliveryRecovery === 'fresh-activation') { + this.retiredSourceDeliveries.activate(ptyId) + this.sourceIdentityByRelayPtyId.delete(ptyId) + getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(appPtyId) + getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(ptyId) + } + while (pendingReattach.queuedData.length > 0) { + await this.acceptPtyData(pendingReattach.queuedData.shift()!) + } + pendingReattach.livePassthrough = true + } const exitAfterActivation = pendingReattach.exits.find( (exit) => !exit.incarnationId || @@ -2124,7 +2445,7 @@ export class SshRelaySession { await this.acceptPtyExit(exitAfterActivation) return } - if (!recoveryRequest) { + if (!recoveryRequest && !targetedDeliveryRecovery) { this.forwardReattachReplay(appPtyId, attachResult.replay ?? '') } sourceActivationLease?.commit() diff --git a/src/main/ssh/system-ssh-forward-process.test.ts b/src/main/ssh/system-ssh-forward-process.test.ts index bc63cf8dc1f..6fae0db15f1 100644 --- a/src/main/ssh/system-ssh-forward-process.test.ts +++ b/src/main/ssh/system-ssh-forward-process.test.ts @@ -27,7 +27,9 @@ vi.mock('net', () => ({ import { SYSTEM_SSH_FORWARD_LISTENER_PROBE_INTERVAL_MS, + SYSTEM_SSH_FORWARD_POST_KILL_TIMEOUT_MS, SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS, + SYSTEM_SSH_FORWARD_STOP_TIMEOUT_MS, spawnSystemSshPortForward, startSystemSshPortForwardProcess, waitForSystemSshForwardStartup, @@ -317,4 +319,66 @@ describe('system SSH forward process', () => { await pending expect(resolved).toBe(true) }) + + it('resolves stop after the post-kill bound when the child never reports exit', async () => { + vi.useFakeTimers() + const child = createFakeProcess() + + let resolved = false + const pending = waitForSystemSshForwardStop(child as never).then(() => { + resolved = true + }) + + await vi.advanceTimersByTimeAsync(SYSTEM_SSH_FORWARD_STOP_TIMEOUT_MS) + expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + await vi.advanceTimersByTimeAsync(SYSTEM_SSH_FORWARD_POST_KILL_TIMEOUT_MS - 1) + expect(resolved).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + + // Why asserted before awaiting `pending`: dropping the post-kill bound is the regression this + // test exists to catch, and awaiting a promise that then never settles reports it as a 30s suite + // timeout instead of a failed assertion. + // Why this is not a death claim: the promise resolving bounds teardown; nothing here records the + // child as gone or drops state that assumes it is. + expect(resolved).toBe(true) + expect(child.kill).toHaveBeenCalledTimes(2) + await pending + }) + + it('clears the post-kill timer once the child exits after SIGKILL', async () => { + vi.useFakeTimers() + const child = createFakeProcess() + + const pending = waitForSystemSshForwardStop(child as never) + await vi.advanceTimersByTimeAsync(SYSTEM_SSH_FORWARD_STOP_TIMEOUT_MS) + child.emit('exit', null) + await expect(pending).resolves.toBeUndefined() + + expect(vi.getTimerCount()).toBe(0) + }) + + it('sends no signal to a child that already exited', async () => { + vi.useFakeTimers() + const child = createFakeProcess() + child.exitCode = 0 + + await expect(waitForSystemSshForwardStop(child as never)).resolves.toBeUndefined() + + expect(child.kill).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('sends no SIGKILL or post-kill timer when the child exits after SIGTERM', async () => { + vi.useFakeTimers() + const child = createFakeProcess() + + const pending = waitForSystemSshForwardStop(child as never) + await vi.advanceTimersByTimeAsync(SYSTEM_SSH_FORWARD_STOP_TIMEOUT_MS - 1) + child.emit('exit', null) + await expect(pending).resolves.toBeUndefined() + + expect(child.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM') + expect(vi.getTimerCount()).toBe(0) + }) }) diff --git a/src/main/ssh/system-ssh-forward-process.ts b/src/main/ssh/system-ssh-forward-process.ts index 13486101bfd..30f5dde0b0b 100644 --- a/src/main/ssh/system-ssh-forward-process.ts +++ b/src/main/ssh/system-ssh-forward-process.ts @@ -6,6 +6,9 @@ import type { SshTarget } from '../../shared/ssh-types' export const SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS = 750 export const SYSTEM_SSH_FORWARD_LISTENER_PROBE_INTERVAL_MS = 50 export const SYSTEM_SSH_FORWARD_STOP_TIMEOUT_MS = 2_000 +// Why short: SIGKILL is uncatchable, so a child that has not reported exit this long after it is +// either already reaped or beyond anything teardown can do about it. +export const SYSTEM_SSH_FORWARD_POST_KILL_TIMEOUT_MS = 500 export type SystemSshPortForwardProcess = { process: ChildProcess @@ -161,8 +164,10 @@ export function waitForSystemSshForwardStartup( export function waitForSystemSshForwardStop(process: ChildProcess): Promise { return new Promise((resolve) => { let settled = false + let postKillTimer: ReturnType | undefined const cleanup = (): void => { clearTimeout(escalationTimer) + clearTimeout(postKillTimer) process.off('exit', onExit) } const finish = (): void => { @@ -193,8 +198,18 @@ export function waitForSystemSshForwardStop(process: ChildProcess): Promise { expect(source.slice(0, willQuitStart)).not.toContain('stopTccPromptNoticeForQuit') }) + it('keeps the power bridge through vetoable before-quit and disposes after commit', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const beforeQuitStart = source.indexOf("app.on('before-quit'") + const willQuitStart = source.indexOf("app.on('will-quit'", beforeQuitStart) + const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) + const beforeQuit = source.slice(beforeQuitStart, willQuitStart) + const willQuit = source.slice(willQuitStart, windowAllClosedStart) + const commitIndex = willQuit.indexOf('quitTeardownStartGate.tryStart(e)') + const disposeIndex = willQuit.indexOf('unsubscribeSystemResumeBroadcast?.()') + + expect(beforeQuitStart).toBeGreaterThanOrEqual(0) + expect(willQuitStart).toBeGreaterThan(beforeQuitStart) + expect(windowAllClosedStart).toBeGreaterThan(willQuitStart) + expect(beforeQuit).not.toContain('unsubscribeSystemResumeBroadcast') + expect(commitIndex).toBeGreaterThanOrEqual(0) + expect(disposeIndex).toBeGreaterThan(commitIndex) + }) + it('starts the automation scheduler before headless serve reports ready', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const serveStart = source.indexOf('if (serveOptions) {') diff --git a/src/main/startup/serve-desktop-activation.ts b/src/main/startup/serve-desktop-activation.ts index 8ee6a83a0e8..2b7ad520891 100644 --- a/src/main/startup/serve-desktop-activation.ts +++ b/src/main/startup/serve-desktop-activation.ts @@ -9,6 +9,24 @@ export type ServeDesktopActivationGate = { markBlocked: (reason: string) => void } +export const SERVE_DESKTOP_ACTIVATION_BLOCKED_REASON = 'persistent PTY provider unavailable' + +/** + * Why: promotion is only safe when serve owns daemon-backed (persistent) PTYs — + * the desktop renderer then reattaches the surviving sessions instead of + * cold-restoring them. Without that provider, fail closed (#8457). + */ +export function settleServeDesktopActivation( + gate: ServeDesktopActivationGate, + options: { hasPersistentPtyProvider: boolean } +): void { + if (!options.hasPersistentPtyProvider) { + gate.markBlocked(SERVE_DESKTOP_ACTIVATION_BLOCKED_REASON) + return + } + gate.markReady() +} + export function createServeDesktopActivationGate(options: { initialState: 'initializing' | 'ready' activateWindow: () => void diff --git a/src/main/system-power-lifecycle.test.ts b/src/main/system-power-lifecycle.test.ts new file mode 100644 index 00000000000..a3536ece7e4 --- /dev/null +++ b/src/main/system-power-lifecycle.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + publishSystemResume, + publishSystemSuspend, + subscribeSystemPowerLifecycle +} from './system-power-lifecycle' + +afterEach(() => { + publishSystemResume() + vi.restoreAllMocks() +}) + +describe('system power lifecycle', () => { + it('replays suspended state to a late subscriber', () => { + publishSystemSuspend() + const listener = { onSuspend: vi.fn(), onResume: vi.fn() } + const unsubscribe = subscribeSystemPowerLifecycle(listener) + + expect(listener.onSuspend).toHaveBeenCalledOnce() + expect(listener.onResume).not.toHaveBeenCalled() + unsubscribe() + }) + + it('atomically replays a transition to a subscriber added during publication', () => { + const lateListener = { onSuspend: vi.fn(), onResume: vi.fn() } + let unsubscribeLate: (() => void) | undefined + const unsubscribeFirst = subscribeSystemPowerLifecycle({ + onSuspend: () => { + unsubscribeLate = subscribeSystemPowerLifecycle(lateListener) + }, + onResume: vi.fn() + }) + + publishSystemSuspend() + + expect(lateListener.onSuspend).toHaveBeenCalledOnce() + unsubscribeLate?.() + unsubscribeFirst() + }) + + it('isolates a failing listener from the remaining subscribers', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const unsubscribeFailing = subscribeSystemPowerLifecycle({ + onSuspend: () => { + throw new Error('listener failed') + }, + onResume: vi.fn() + }) + const healthyListener = { onSuspend: vi.fn(), onResume: vi.fn() } + const unsubscribeHealthy = subscribeSystemPowerLifecycle(healthyListener) + + publishSystemSuspend() + + expect(healthyListener.onSuspend).toHaveBeenCalledOnce() + expect(console.error).toHaveBeenCalledOnce() + unsubscribeHealthy() + unsubscribeFailing() + }) +}) diff --git a/src/main/system-power-lifecycle.ts b/src/main/system-power-lifecycle.ts new file mode 100644 index 00000000000..6aea98711dd --- /dev/null +++ b/src/main/system-power-lifecycle.ts @@ -0,0 +1,45 @@ +export type SystemPowerLifecycleListener = { + onSuspend: () => void + onResume: () => void +} + +type SystemPowerState = 'awake' | 'suspended' + +const listeners = new Set() +let state: SystemPowerState = 'awake' + +function notifyListener(listener: SystemPowerLifecycleListener, nextState: SystemPowerState): void { + try { + if (nextState === 'suspended') { + listener.onSuspend() + } else { + listener.onResume() + } + } catch (error) { + console.error('[power] System lifecycle listener failed:', error) + } +} + +export function subscribeSystemPowerLifecycle(listener: SystemPowerLifecycleListener): () => void { + listeners.add(listener) + notifyListener(listener, state) + return () => listeners.delete(listener) +} + +export function publishSystemSuspend(): void { + state = 'suspended' + for (const listener of Array.from(listeners)) { + if (listeners.has(listener)) { + notifyListener(listener, state) + } + } +} + +export function publishSystemResume(): void { + state = 'awake' + for (const listener of Array.from(listeners)) { + if (listeners.has(listener)) { + notifyListener(listener, state) + } + } +} diff --git a/src/main/system-resume-broadcast.test.ts b/src/main/system-resume-broadcast.test.ts index 50859d26baf..54d54490257 100644 --- a/src/main/system-resume-broadcast.test.ts +++ b/src/main/system-resume-broadcast.test.ts @@ -4,6 +4,7 @@ import { getCrashBreadcrumbSnapshot } from './crash-reporting/crash-breadcrumb-store' import { registerSystemResumeBroadcast, SYSTEM_RESUMED_CHANNEL } from './system-resume-broadcast' +import { subscribeSystemPowerLifecycle } from './system-power-lifecycle' vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: vi.fn(() => []) }, @@ -51,6 +52,27 @@ function createWindow(destroyed = false): { } describe('registerSystemResumeBroadcast', () => { + it('publishes suspend and resume to main-process lifecycle consumers', () => { + const { source, fireSuspend, fireResume } = createResumeSource() + const listener = { onSuspend: vi.fn(), onResume: vi.fn() } + const unsubscribe = subscribeSystemPowerLifecycle(listener) + listener.onResume.mockClear() + const stopBroadcast = registerSystemResumeBroadcast({ + resumeSource: source, + getWindows: () => [] + }) + + fireSuspend() + fireResume() + unsubscribe() + fireSuspend() + fireResume() + stopBroadcast() + + expect(listener.onSuspend).toHaveBeenCalledOnce() + expect(listener.onResume).toHaveBeenCalledOnce() + }) + it('broadcasts the resume channel to every live window', () => { const { source, fireResume } = createResumeSource() const liveWindow = createWindow() diff --git a/src/main/system-resume-broadcast.ts b/src/main/system-resume-broadcast.ts index 98ad884572a..5ae10cdc884 100644 --- a/src/main/system-resume-broadcast.ts +++ b/src/main/system-resume-broadcast.ts @@ -1,5 +1,6 @@ import { BrowserWindow, powerMonitor } from 'electron' import { recordCrashBreadcrumb } from './crash-reporting/crash-breadcrumb-store' +import { publishSystemResume, publishSystemSuspend } from './system-power-lifecycle' export const SYSTEM_RESUMED_CHANNEL = 'system:resumed' @@ -45,6 +46,7 @@ export function registerSystemResumeBroadcast( // span is visibly inconsistent with surviving heartbeats, while under-reporting // leaves a long gap looking unexplained -- the false freeze this exists to rule out. suspendedAt ??= now() + publishSystemSuspend() } const onResume = (): void => { @@ -53,6 +55,7 @@ export function registerSystemResumeBroadcast( if (suspendedForMs !== null && suspendedForMs >= MIN_REPORTABLE_SUSPEND_MS) { recordCrashBreadcrumb('system_slept', { suspendedForMs }) } + publishSystemResume() for (const window of getWindows()) { if (!window.isDestroyed()) { window.webContents.send(SYSTEM_RESUMED_CHANNEL) diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 3048b2d0f87..885f24d1667 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -7,6 +7,7 @@ import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' import { sourceControlAiSettingsFromLegacy } from '../../shared/source-control-ai' +import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' import type { GlobalSettings } from '../../shared/types' import { cancelGenerateCommitMessageLocal, @@ -717,6 +718,27 @@ describe('generateCommitMessageFromContext', () => { }) }) + it('reports remote model discovery transport timeouts without PATH guidance', async () => { + const transportTimeout = Object.assign( + new Error('Request "agent.execNonInteractive" timed out after 65000ms'), + { code: SSH_MUX_REQUEST_TIMEOUT_CODE } + ) + const result = await discoverCommitMessageModelsRemote( + 'cursor', + '/remote/repo', + async () => { + throw transportTimeout + }, + 'npx cursor-agent' + ) + + expect(result).toEqual({ + success: false, + error: + 'Cursor model discovery took longer than 60s and may still be running on the remote host.' + }) + }) + it('reports remote model discovery spawn failures with remote install guidance', async () => { const result = await discoverCommitMessageModelsRemote('cursor', '/remote/repo', async () => ({ stdout: '', @@ -1229,6 +1251,39 @@ describe('generateCommitMessageFromContext', () => { }) }) + it('reports a remote transport timeout without claiming the agent is unreachable', async () => { + const transportTimeout = Object.assign( + new Error('Request "agent.execNonInteractive" timed out after 65000ms'), + { code: SSH_MUX_REQUEST_TIMEOUT_CODE } + ) + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => { + throw transportTimeout + } + } + ) + + expect(result).toEqual({ + success: false, + error: 'agent took longer than 60s to respond and may still be running on the remote host.', + canceled: undefined + }) + }) + it('sanitizes remote execution transport failures', async () => { const result = await generateCommitMessageFromContext( { @@ -2307,18 +2362,32 @@ describe('linkedIssue template substitution', () => { expect(prompt).not.toContain('linkedIssue') }) - it('leaves the built-in pull-request prompt free of issue guidance', async () => { + it('includes the linked issue in the built-in pull-request prompt', async () => { let prompt = '' await generatePullRequestFieldsFromContext( - { ...PULL_REQUEST_CONTEXT, linkedIssue: BUILT_IN_PROMPT_SENTINEL_ISSUE }, + { + ...PULL_REQUEST_CONTEXT, + linkedIssue: BUILT_IN_PROMPT_SENTINEL_ISSUE, + provider: 'gitlab', + linkedIssueDetails: { + provider: 'gitlab', + number: BUILT_IN_PROMPT_SENTINEL_ISSUE, + title: 'Stop phantom polling', + description: 'Avoid paths that cannot exist on this host.' + } + }, builtInPromptParams, capturingTarget((value) => { prompt = value }) ) - expect(prompt).not.toContain(String(BUILT_IN_PROMPT_SENTINEL_ISSUE)) - expect(prompt).not.toContain('linkedIssue') + expect(prompt).toContain(`Linked GitLab issue: #${BUILT_IN_PROMPT_SENTINEL_ISSUE}`) + expect(prompt).toContain(`Closes #${BUILT_IN_PROMPT_SENTINEL_ISSUE}`) + expect(prompt).toContain(`Related to #${BUILT_IN_PROMPT_SENTINEL_ISSUE}`) + expect(prompt).toContain('Stop phantom polling') + expect(prompt).toContain('Avoid paths that cannot exist on this host.') + expect(prompt).not.toContain('GitHub issue') }) it('substitutes the linked issue into the pull-request prompt', async () => { diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 7115ce9d3f4..9a2acbee279 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -59,6 +59,7 @@ import { import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { wslAwareSpawn } from '../git/runner' import { terminateWindowsProcessTree } from '../windows-process-tree-kill' +import { isSshMuxRequestTimeoutError } from '../ssh/ssh-channel-multiplexer' const GENERATION_TIMEOUT_MS = 60_000 const MAX_AGENT_OUTPUT_BYTES = 4 * 1024 * 1024 @@ -519,6 +520,12 @@ export async function discoverCommitMessageModelsRemote( result = await execute(planned.plan, cwd, GENERATION_TIMEOUT_MS) } catch (error) { console.error('[commit-message] Remote model discovery request failed:', error) + if (isSshMuxRequestTimeoutError(error)) { + return { + success: false, + error: `${spec.label} model discovery took longer than ${GENERATION_TIMEOUT_MS / 1000}s and may still be running on the remote host.` + } + } return { success: false, error: `${spec.label} model discovery could not be reached on the remote PATH. Try again after the SSH connection recovers.` @@ -996,6 +1003,12 @@ async function runRemotePlan( result = await target.execute(plan, target.cwd, GENERATION_TIMEOUT_MS, operation) } catch (error) { console.error('[commit-message] Remote generator request failed:', error) + if (isSshMuxRequestTimeoutError(error)) { + return { + success: false, + error: `${label} took longer than ${GENERATION_TIMEOUT_MS / 1000}s to respond and may still be running on the remote host.` + } + } return { success: false, error: `${label} could not be reached on the ${target.missingBinaryLocation}. Try again after the SSH connection recovers.` diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 0764fa3ad93..bc595f10b20 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -2742,7 +2742,7 @@ describe('createMainWindow', () => { expect(webContents.send).toHaveBeenCalledWith('ui:toggleLeftSidebar') }) - it('shows spellcheck context menu for editable text without relying on markdown focus mirror', () => { + it('opens a table-aware context menu synchronously without a renderer query', () => { const windowHandlers: Record void> = {} const webContents = { on: vi.fn((event, handler) => { @@ -2778,12 +2778,22 @@ describe('createMainWindow', () => { createMainWindow(null) + const tableTargetListener = vi + .mocked(ipcMain.on) + .mock.calls.find(([channel]) => channel === 'rich-markdown:context-target')?.[1] + tableTargetListener?.({ sender: webContents } as never, { + cellType: 'body', + targetId: 'table-target', + x: 42, + y: 84 + }) windowHandlers['context-menu']( {} as never, { x: 42, y: 84, isEditable: true, + formControlType: 'none', spellcheckEnabled: true, dictionarySuggestions: ['reference'], misspelledWord: 'refrence' @@ -2791,7 +2801,10 @@ describe('createMainWindow', () => { ) expect(buildFromTemplateMock).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ label: 'reference' })]) + expect.arrayContaining([ + expect.objectContaining({ label: 'reference' }), + expect.objectContaining({ label: 'Table' }) + ]) ) expect(menuPopupMock).toHaveBeenCalledWith({ window: browserWindowInstance, x: 42, y: 84 }) }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 3dc853e64a6..327772164f4 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -42,7 +42,15 @@ import { type KeybindingOverrides } from '../../shared/keybindings' import { getMainE2EConfig } from '../e2e-config' -import { buildEditableContextMenuTemplate } from './editable-context-menu' +import { + buildEditableContextMenuTemplate, + matchingRichMarkdownContextMenuTableTarget, + parseRichMarkdownContextMenuTableTarget +} from './editable-context-menu' +import { + richMarkdownContextMenuTargetChannel, + type RichMarkdownContextMenuTableTarget +} from '../../shared/rich-markdown-context-menu' import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui' import { resolveWindowCloseAction } from './window-close-decision' import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' @@ -527,9 +535,24 @@ export function createMainWindow( } ipcMain.on(shortcutRecorderFocusChannel, onShortcutRecorderFocused) + let pendingRichMarkdownContextMenuTableTarget: RichMarkdownContextMenuTableTarget | null = null + const onRichMarkdownContextMenuTarget = (event: Electron.IpcMainEvent, value: unknown): void => { + if (event.sender !== mainWindow.webContents) { + return + } + pendingRichMarkdownContextMenuTableTarget = parseRichMarkdownContextMenuTableTarget(value) + } + ipcMain.on(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget) const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { - const template = buildEditableContextMenuTemplate(params, mainWindow.webContents) - if (template.length === 0) { + const tableTarget = matchingRichMarkdownContextMenuTableTarget( + params, + pendingRichMarkdownContextMenuTableTarget + ) + pendingRichMarkdownContextMenuTableTarget = null + const template = buildEditableContextMenuTemplate(params, mainWindow.webContents, { + tableTarget + }) + if (template.length === 0 || mainWindow.isDestroyed()) { return } // Why: the context-menu event can precede our focus-mirror update; trust Electron's editable params, not markdownEditorFocused. @@ -540,6 +563,7 @@ export function createMainWindow( // Why: a dead renderer can't clear its focus mirror; default-deny carve-outs so it can't disable app shortcuts in a later lifecycle. const resetMarkdownEditorFocus = (): void => { markdownEditorFocused = false + pendingRichMarkdownContextMenuTableTarget = null } const resetTerminalInputFocus = (): void => { terminalInputFocused = false @@ -1121,6 +1145,7 @@ export function createMainWindow( ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused) ipcMain.removeListener(floatingFocusChannel, onFloatingFocus) ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) + ipcMain.removeListener(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget) // Why: powerMonitor is app-global; without this the resume relay leaks and fires against a destroyed webContents. powerMonitor.removeListener('resume', onSystemResume) clearTrustedUIRendererWebContentsId(rendererWebContentsId) diff --git a/src/main/window/editable-context-menu.test.ts b/src/main/window/editable-context-menu.test.ts index 23252f22c03..b057803d774 100644 --- a/src/main/window/editable-context-menu.test.ts +++ b/src/main/window/editable-context-menu.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { buildEditableContextMenuTemplate } from './editable-context-menu' +import { + buildEditableContextMenuTemplate, + matchingRichMarkdownContextMenuTableTarget, + parseRichMarkdownContextMenuTableTarget +} from './editable-context-menu' import { richMarkdownContextMenuCommandChannel } from '../../shared/rich-markdown-context-menu' function contextParams( @@ -80,7 +84,8 @@ describe('buildEditableContextMenuTemplate', () => { replaceMisspelling: vi.fn(), send, session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session - } + }, + { tableTarget: { cellType: 'body', targetId: 'table-target-1', x: 12, y: 34 } } ) expect(template.map((item) => item.label ?? item.role ?? item.type)).toEqual([ @@ -89,6 +94,7 @@ describe('buildEditableContextMenuTemplate', () => { 'Format', 'Paragraph', 'Insert', + 'Table', 'separator', 'cut', 'copy', @@ -140,12 +146,62 @@ describe('buildEditableContextMenuTemplate', () => { y: 34 }) - template[8].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + const tableMenu = template[5].submenu as Electron.MenuItemConstructorOptions[] + expect(tableMenu.map((item) => item.label ?? item.type)).toEqual([ + 'Insert row above', + 'Insert row below', + 'Delete row', + 'separator', + 'Insert column left', + 'Insert column right', + 'Delete column', + 'separator', + 'Delete table' + ]) + tableMenu[0].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + expect(send).toHaveBeenLastCalledWith(richMarkdownContextMenuCommandChannel, { + command: 'insert-row-above', + tableTargetId: 'table-target-1', + x: 12, + y: 34 + }) + template[9].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) + template[10].click?.({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent) expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: false }) expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: true }) }) + it('omits table actions when the context target is outside a table', () => { + const template = buildEditableContextMenuTemplate( + contextParams({ misspelledWord: '', dictionarySuggestions: [] }), + { + replaceMisspelling: vi.fn(), + send: vi.fn(), + session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session + } + ) + + expect(template.map((item) => item.label ?? item.role ?? item.type)).not.toContain('Table') + }) + + it('disables native row actions that cannot cross the Markdown header boundary', () => { + const template = buildEditableContextMenuTemplate( + contextParams({ x: 12, y: 34, misspelledWord: '', dictionarySuggestions: [] }), + { + replaceMisspelling: vi.fn(), + send: vi.fn(), + session: { addWordToSpellCheckerDictionary: vi.fn() } as unknown as Electron.Session + }, + { tableTarget: { cellType: 'header', targetId: 'header-target', x: 12, y: 34 } } + ) + const tableMenu = template[5].submenu as Electron.MenuItemConstructorOptions[] + + expect(tableMenu[0]).toMatchObject({ label: 'Insert row above', enabled: false }) + expect(tableMenu[1]).toMatchObject({ label: 'Insert row below' }) + expect(tableMenu[2]).toMatchObject({ label: 'Delete row', enabled: false }) + }) + it('does not build a menu outside editable text', () => { const webContents = { replaceMisspelling: vi.fn(), @@ -217,3 +273,28 @@ describe('buildEditableContextMenuTemplate', () => { expect(send).toHaveBeenCalledWith('ui:editableContextPaste', { plainTextOnly: true }) }) }) + +describe('rich markdown context-menu table targets', () => { + const target = { cellType: 'body' as const, targetId: 'table-target', x: 12, y: 34 } + + it('accepts a valid renderer-reported table target', () => { + expect(parseRichMarkdownContextMenuTableTarget(target)).toEqual(target) + expect( + matchingRichMarkdownContextMenuTableTarget(contextParams({ x: 12, y: 34 }), target) + ).toEqual(target) + }) + + it('rejects malformed, stale, and non-rich targets', () => { + expect(parseRichMarkdownContextMenuTableTarget({ ...target, x: Number.NaN })).toBeNull() + expect(parseRichMarkdownContextMenuTableTarget({ ...target, cellType: 'footer' })).toBeNull() + expect( + matchingRichMarkdownContextMenuTableTarget(contextParams({ x: 13, y: 34 }), target) + ).toBeNull() + expect( + matchingRichMarkdownContextMenuTableTarget( + contextParams({ x: 12, y: 34, formControlType: 'input-text' }), + target + ) + ).toBeNull() + }) +}) diff --git a/src/main/window/editable-context-menu.ts b/src/main/window/editable-context-menu.ts index da9dded645d..c4e9b8cc596 100644 --- a/src/main/window/editable-context-menu.ts +++ b/src/main/window/editable-context-menu.ts @@ -1,8 +1,10 @@ import { richMarkdownContextMenuCommandChannel, type RichMarkdownContextMenuCommand, - type RichMarkdownContextMenuCommandPayload + type RichMarkdownContextMenuCommandPayload, + type RichMarkdownContextMenuTableTarget } from '../../shared/rich-markdown-context-menu' +import { translateMain } from '../i18n/main-i18n' type EditableContextMenuWebContents = Pick< Electron.WebContents, @@ -13,12 +15,17 @@ function markdownCommandItem( label: string, command: RichMarkdownContextMenuCommand, webContents: EditableContextMenuWebContents, - point: { x: number; y: number } + point: { x: number; y: number }, + tableTargetId?: string ): Electron.MenuItemConstructorOptions { return { label, click: () => { - const payload: RichMarkdownContextMenuCommandPayload = { command, ...point } + const payload: RichMarkdownContextMenuCommandPayload = { + command, + ...point, + ...(tableTargetId ? { tableTargetId } : {}) + } webContents.send(richMarkdownContextMenuCommandChannel, payload) } } @@ -44,7 +51,8 @@ function editableContextPasteItem( function buildMarkdownMenuTemplate( webContents: EditableContextMenuWebContents, - point: { x: number; y: number } + point: { x: number; y: number }, + tableTarget: RichMarkdownContextMenuTableTarget | null ): Electron.MenuItemConstructorOptions[] { return [ markdownCommandItem('Add link', 'add-link', webContents, point), @@ -84,6 +92,84 @@ function buildMarkdownMenuTemplate( markdownCommandItem('Code block', 'code-block', webContents, point) ] }, + ...(tableTarget + ? [ + { + label: translateMain('auto.main.window.editableContextMenu.table', 'Table'), + submenu: [ + { + ...markdownCommandItem( + translateMain( + 'auto.main.window.editableContextMenu.insertRowAbove', + 'Insert row above' + ), + 'insert-row-above', + webContents, + point, + tableTarget.targetId + ), + enabled: tableTarget.cellType !== 'header' + }, + markdownCommandItem( + translateMain( + 'auto.main.window.editableContextMenu.insertRowBelow', + 'Insert row below' + ), + 'insert-row-below', + webContents, + point, + tableTarget.targetId + ), + { + ...markdownCommandItem( + translateMain('auto.main.window.editableContextMenu.deleteRow', 'Delete row'), + 'delete-row', + webContents, + point, + tableTarget.targetId + ), + enabled: tableTarget.cellType !== 'header' + }, + { type: 'separator' as const }, + markdownCommandItem( + translateMain( + 'auto.main.window.editableContextMenu.insertColumnLeft', + 'Insert column left' + ), + 'insert-column-left', + webContents, + point, + tableTarget.targetId + ), + markdownCommandItem( + translateMain( + 'auto.main.window.editableContextMenu.insertColumnRight', + 'Insert column right' + ), + 'insert-column-right', + webContents, + point, + tableTarget.targetId + ), + markdownCommandItem( + translateMain('auto.main.window.editableContextMenu.deleteColumn', 'Delete column'), + 'delete-column', + webContents, + point, + tableTarget.targetId + ), + { type: 'separator' as const }, + markdownCommandItem( + translateMain('auto.main.window.editableContextMenu.deleteTable', 'Delete table'), + 'delete-table', + webContents, + point, + tableTarget.targetId + ) + ] + } + ] + : []), { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, @@ -107,7 +193,8 @@ function buildNativeEditMenuTemplate( export function buildEditableContextMenuTemplate( params: Electron.ContextMenuParams, - webContents: EditableContextMenuWebContents + webContents: EditableContextMenuWebContents, + options?: { tableTarget?: RichMarkdownContextMenuTableTarget | null } ): Electron.MenuItemConstructorOptions[] { if (!params.isEditable) { return [] @@ -137,9 +224,55 @@ export function buildEditableContextMenuTemplate( } template.push( ...(isRichMarkdownSurface - ? buildMarkdownMenuTemplate(webContents, { x: params.x, y: params.y }) + ? buildMarkdownMenuTemplate( + webContents, + { x: params.x, y: params.y }, + options?.tableTarget ?? null + ) : buildNativeEditMenuTemplate(webContents)) ) return template } + +export function parseRichMarkdownContextMenuTableTarget( + value: unknown +): RichMarkdownContextMenuTableTarget | null { + if (!value || typeof value !== 'object') { + return null + } + const target = value as Partial + if ( + (target.cellType !== 'body' && target.cellType !== 'header') || + typeof target.targetId !== 'string' || + target.targetId.length === 0 || + typeof target.x !== 'number' || + !Number.isFinite(target.x) || + typeof target.y !== 'number' || + !Number.isFinite(target.y) + ) { + return null + } + return { + cellType: target.cellType, + targetId: target.targetId, + x: target.x, + y: target.y + } +} + +export function matchingRichMarkdownContextMenuTableTarget( + params: Electron.ContextMenuParams, + target: RichMarkdownContextMenuTableTarget | null +): RichMarkdownContextMenuTableTarget | null { + if ( + !target || + !params.isEditable || + params.formControlType !== 'none' || + params.x !== target.x || + params.y !== target.y + ) { + return null + } + return target +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 93b4a2e4d23..53bdb0e7349 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -30,8 +30,10 @@ import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { ReleaseChannel } from '../shared/release-channel' import type { + HostQualifiedKnownWorktreeResult, HostQualifiedDetectedWorktreeResult, LegacyDetectedWorktreeRequest, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs, ProviderRequestId } from '../shared/detected-worktree-provider-contract' @@ -124,6 +126,7 @@ import type { BrowserCertificateProceedResult, BrowserLoadError, BrowserSessionProfile, + BrowserSessionProfileCreateOptions, BrowserSessionProfileScope, BrowserSessionProfileSource, BrowserViewportOverride, @@ -329,7 +332,10 @@ import type { UpdatePullRequestBySlugArgs, UpdateProjectItemFieldArgs } from '../shared/github-project-types' -import type { RichMarkdownContextMenuCommandPayload } from '../shared/rich-markdown-context-menu' +import type { + RichMarkdownContextMenuCommandPayload, + RichMarkdownContextMenuTableTarget +} from '../shared/rich-markdown-context-menu' import type { BrowserSetGrabModeArgs, BrowserSetGrabModeResult, @@ -617,10 +623,12 @@ export type BrowserApi = { callback: (args: { browserPageId: string; key: 'c' | 's' }) => void ) => () => void sessionListProfiles: () => Promise - sessionCreateProfile: (args: { - scope: BrowserSessionProfileScope - label: string - }) => Promise + sessionCreateProfile: ( + args: { + scope: BrowserSessionProfileScope + label: string + } & BrowserSessionProfileCreateOptions + ) => Promise sessionDeleteProfile: (args: { profileId: string }) => Promise sessionImportCookies: (args: { profileId: string }) => Promise sessionResolvePartition: (args: { profileId: string | null }) => Promise @@ -1393,6 +1401,9 @@ export type PreloadApi = { ): Promise (args: LegacyDetectedWorktreeRequest): Promise } + listKnownForExecutionHost?: ( + args: ListKnownWorktreesForExecutionHostArgs + ) => Promise cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise @@ -2892,9 +2903,19 @@ export type PreloadApi = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string requestToken?: string }) => Promise cancelStatus: (args: { requestToken: string }) => Promise + setStatusUpstreamRefWatch: (args: { + worktreeId: string + worktreePath: string + executionHostId: string + connectionId?: string + branch?: string + upstreamName?: string + }) => Promise submoduleStatus: (args: { worktreePath: string submodulePath: string @@ -3298,6 +3319,7 @@ export type PreloadApi = { setZoomLevel: (level: number) => void syncTrafficLights: (zoomFactor: number) => void setMarkdownEditorFocused: (focused: boolean) => void + setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null) => void setTerminalInputFocused: (focused: boolean) => void setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }) => void setShortcutRecorderFocused: (focused: boolean) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index d54af396aca..cf602b3f1cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -185,7 +185,9 @@ import type { } from '../shared/github-project-types' import { richMarkdownContextMenuCommandChannel, - type RichMarkdownContextMenuCommandPayload + richMarkdownContextMenuTargetChannel, + type RichMarkdownContextMenuCommandPayload, + type RichMarkdownContextMenuTableTarget } from '../shared/rich-markdown-context-menu' import type { AgentStatusClearIpcPayload, @@ -764,6 +766,9 @@ const api = { listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + listKnownForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), listAll: () => ipcRenderer.invoke('worktrees:listAll'), @@ -2743,6 +2748,7 @@ const api = { sessionCreateProfile: (args: { scope: 'default' | 'isolated' | 'imported' label: string + userAgentMode?: 'clean' | 'native' }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), sessionDeleteProfile: (args: { profileId: string }): Promise => @@ -3227,10 +3233,19 @@ const api = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string }): Promise => ipcRenderer.invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => ipcRenderer.invoke('git:cancelStatus', args), + setStatusUpstreamRefWatch: (args: { + worktreeId: string + worktreePath: string + executionHostId: string + connectionId?: string + branch?: string + upstreamName?: string + }): Promise => ipcRenderer.invoke('git:setStatusUpstreamRefWatch', args), submoduleStatus: (args: { worktreePath: string submodulePath: string @@ -4028,6 +4043,9 @@ const api = { setMarkdownEditorFocused: (focused: boolean): void => { ipcRenderer.send('ui:setMarkdownEditorFocused', focused) }, + setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null): void => { + ipcRenderer.send(richMarkdownContextMenuTargetChannel, target) + }, setTerminalInputFocused: (focused: boolean): void => { ipcRenderer.send('ui:setTerminalInputFocused', focused) }, diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts index 7a81df602d2..04f13430801 100644 --- a/src/preload/ssh-authority-forwarding.test.ts +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -68,6 +68,16 @@ describe('native preload SSH authority forwarding', () => { >() }) + it('forwards host-qualified metadata-only worktree reads', async () => { + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + const args = { repoId: 'repo-1', executionHostId: 'ssh:ssh-1' as const } + + await api.worktrees.listKnownForExecutionHost?.(args) + + expect(invoke).toHaveBeenCalledWith('worktrees:listKnownForExecutionHost', args) + }) + it('forwards full-pair get and push states without cloning away authority', async () => { const state: SshConnectionState = { targetId: 'ssh-1', diff --git a/src/relay/dispatcher-client-close-cause.test.ts b/src/relay/dispatcher-client-close-cause.test.ts new file mode 100644 index 00000000000..2eb926900d5 --- /dev/null +++ b/src/relay/dispatcher-client-close-cause.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' +import { DISPATCHER_CONTROL_QUEUE_MAX_FRAMES } from './dispatcher-writer-admission' + +// A detach carries the reason the client went away, because the PTY owner grace is only safe to +// shorten against an owner the relay watched leave. Everything here is about keeping those two +// answers apart. +describe('RelayDispatcher client close cause', () => { + it('reports a backpressure teardown as a relay-local close, not a peer close', () => { + // Why this matters beyond the argument shape: "capacity exceeded" is what a client that is alive + // but slow to drain looks like, and a consumer that read this as a peer close would shorten that + // owner's grace and hand its session to someone else while it is still there. + const detachListener = vi.fn() + const settlements: ((result: SinkWriteSettlement) => void)[] = [] + const dispatcher = new RelayDispatcher( + (_data, onSettled) => { + settlements.push(onSettled) + return true + }, + { supportsWriteCallback: true } + ) + try { + dispatcher.onClientDetached(detachListener) + + // Every write stays in flight, so the control lane fills and the relay destroys its own client. + for (let frame = 0; frame <= DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; frame += 1) { + dispatcher.notifyControl('control.fill', { frame }) + } + + expect(detachListener).toHaveBeenCalledWith(1, 'local') + } finally { + dispatcher.dispose() + } + }) + + it('reports a detach the transport observed as a peer close', () => { + const detachListener = vi.fn() + const dispatcher = new RelayDispatcher(() => true) + try { + dispatcher.onClientDetached(detachListener) + const clientId = dispatcher.attachClient(() => true) + + // Why the caller states it: only the socket layer sees the peer's transport end, so the + // evidence has to travel from there rather than be inferred here. + dispatcher.detachClient(clientId, 'peer-closed') + + expect(detachListener).toHaveBeenCalledWith(clientId, 'peer-closed') + } finally { + dispatcher.dispose() + } + }) + + it('defaults an unqualified detach to the cautious answer', () => { + const detachListener = vi.fn() + const dispatcher = new RelayDispatcher(() => true) + try { + dispatcher.onClientDetached(detachListener) + const clientId = dispatcher.attachClient(() => true) + + dispatcher.detachClient(clientId) + + expect(detachListener).toHaveBeenCalledWith(clientId, 'local') + } finally { + dispatcher.dispose() + } + }) +}) diff --git a/src/relay/dispatcher-client-writer.ts b/src/relay/dispatcher-client-writer.ts index dd9a2e3b245..514ee47b8da 100644 --- a/src/relay/dispatcher-client-writer.ts +++ b/src/relay/dispatcher-client-writer.ts @@ -5,6 +5,7 @@ import { type DispatcherWriterEntry, type DispatcherWriterLane } from './dispatcher-writer-admission' +import { DispatcherWriterDrainArm } from './dispatcher-writer-drain-arm' import { DispatcherWriterLaneScheduler } from './dispatcher-writer-lane-scheduler' import { DispatcherWriterSink, @@ -33,11 +34,11 @@ export class DispatcherClientWriter { private readonly settleOnDrain = new Set() private readonly capacityListeners = new Set<() => void>() private readonly idleWaiters = new Set<() => void>() + private readonly drain = new DispatcherWriterDrainArm() private saturated = false - private drainArmed = false - private removeDrainListener: (() => void) | null = null private livenessBypassOutstanding = false private pumping = false + private retiredCapacity = false private closed = false private closeNotified = false @@ -88,7 +89,8 @@ export class DispatcherClientWriter { encode: () => Buffer, estimatedBytes: number, onSettled: (result: SinkWriteSettlement) => void = () => {}, - overflowIsNonFatal = false + overflowIsNonFatal = false, + isStillAdmitted?: () => boolean ): boolean { if (this.closed) { onSettled({ ok: false, error: new Error('Relay writer is closed') }) @@ -97,6 +99,7 @@ export class DispatcherClientWriter { const entry: DispatcherWriterEntry = { lane, encode, + isStillAdmitted, estimatedBytes, onSettled: onceDispatcherWriterSettlement(onSettled), settled: false, @@ -126,8 +129,7 @@ export class DispatcherClientWriter { } this.closed = true this.saturated = false - this.drainArmed = false - this.clearDrainListener() + this.drain.disarm() for (const entry of this.admission.takeQueued()) { this.releaseEntry(entry, { ok: false, error }) } @@ -167,6 +169,12 @@ export class DispatcherClientWriter { } finally { this.pumping = false } + // Why deferred: retiring a deep queue drops every entry in one pass, and notifying per drop + // fans out to each listener O(queue) times for capacity that only changed once. + if (this.retiredCapacity && !this.closed) { + this.retiredCapacity = false + this.notifyCapacity() + } } private selectNext(): DispatcherWriterEntry | undefined { @@ -197,6 +205,11 @@ export class DispatcherClientWriter { } private writeEntry(entry: DispatcherWriterEntry): void { + if (entry.isStillAdmitted && !entry.isStillAdmitted()) { + this.releaseEntry(entry, { ok: false, error: new Error('PTY publication retired') }) + this.retiredCapacity = true + return + } this.laneScheduler.recordWrite(entry.lane) this.inFlight.add(entry) let callbackResult: SinkWriteSettlement | undefined @@ -247,30 +260,21 @@ export class DispatcherClientWriter { } private armDrain(): void { - if (this.drainArmed) { - return - } - this.drainArmed = true try { - const registration = this.sink.registerDrain(() => this.handleDrain()) - if (!registration.registered) { - this.drainArmed = false - } else if (this.drainArmed) { - this.removeDrainListener = registration.remove - } else { - registration.remove() - } + this.drain.arm( + (onDrain) => this.sink.registerDrain(onDrain), + () => this.handleDrain() + ) } catch (error) { this.close(error instanceof Error ? error : new Error(String(error))) } } private handleDrain(): void { - if (this.closed || !this.drainArmed) { + if (this.closed || !this.drain.isArmed) { return } - this.drainArmed = false - this.clearDrainListener() + this.drain.disarm() this.saturated = false this.livenessBypassOutstanding = false for (const entry of Array.from(this.settleOnDrain)) { @@ -299,12 +303,6 @@ export class DispatcherClientWriter { } } - private clearDrainListener(): void { - const remove = this.removeDrainListener - this.removeDrainListener = null - remove?.() - } - private isIdle = (): boolean => this.inFlight.size === 0 && this.admission.queuedEntries === 0 private notifyIdle(): void { diff --git a/src/relay/dispatcher-writer-admission.ts b/src/relay/dispatcher-writer-admission.ts index 27504697f9f..71865b3c1f0 100644 --- a/src/relay/dispatcher-writer-admission.ts +++ b/src/relay/dispatcher-writer-admission.ts @@ -12,6 +12,7 @@ export type DispatcherWriterSettlement = { ok: true } | { ok: false; error: Erro export type DispatcherWriterEntry = { lane: DispatcherWriterLane encode: () => Buffer + isStillAdmitted?: () => boolean estimatedBytes: number onSettled: (result: DispatcherWriterSettlement) => void settled: boolean diff --git a/src/relay/dispatcher-writer-drain-arm.ts b/src/relay/dispatcher-writer-drain-arm.ts new file mode 100644 index 00000000000..d0076e2958c --- /dev/null +++ b/src/relay/dispatcher-writer-drain-arm.ts @@ -0,0 +1,39 @@ +type DrainRegistration = { registered: boolean; remove: () => void } + +/** + * Holds the writer's single outstanding drain registration on its sink. + * + * Why it is re-checked after registering: a sink may invoke the drain callback synchronously from + * inside registerDrain, which disarms this arm before registration returns. Storing the remove + * handle then would cancel a later, still-wanted arm instead of the one that already fired. + */ +export class DispatcherWriterDrainArm { + private armed = false + private remove: (() => void) | null = null + + get isArmed(): boolean { + return this.armed + } + + arm(register: (onDrain: () => void) => DrainRegistration, onDrain: () => void): void { + if (this.armed) { + return + } + this.armed = true + const registration = register(onDrain) + if (!registration.registered) { + this.armed = false + } else if (this.armed) { + this.remove = registration.remove + } else { + registration.remove() + } + } + + disarm(): void { + this.armed = false + const remove = this.remove + this.remove = null + remove?.() + } +} diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 3ec57dcf899..f38d3833dca 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -411,7 +411,9 @@ describe('RelayDispatcher', () => { dispatcher.invalidateClient() - expect(listener).toHaveBeenCalledWith(1) + // Why the cause is asserted: an unqualified invalidate is the relay's own decision, and only a + // caller that watched the peer's transport end may report 'peer-closed'. + expect(listener).toHaveBeenCalledWith(1, 'local') }) it('detaches the primary client when its write throws (frame lost, trigger reconnect)', () => { @@ -436,7 +438,8 @@ describe('RelayDispatcher', () => { // Fix: the write failure detaches the primary so the reconnect/reattach // machinery runs promptly instead of waiting for keepalive timeout. - expect(detachListener).toHaveBeenCalledWith(1) + // Why 'local': a throwing sink is this relay's write failing, not observed proof the peer left. + expect(detachListener).toHaveBeenCalledWith(1, 'local') // Recovery: a reconnecting socket swaps the write via setWrite; the client // is usable again and later frames flow to the new sink. diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index d89d0795ed9..c95eef32117 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -27,6 +27,7 @@ import { LegacyRelayPublicationLedger, type LegacyPublicationLease } from './legacy-relay-publication-ledger' +import type { PtyConsumerCloseCause } from '../shared/pty-consumer-session-contract' export type { RelayClientSinkOptions, @@ -54,6 +55,11 @@ export type RelayClientSourceOptions = { resumeReads?: () => void } +export type PtyDataPublicationAdmission = ( + clientId: number, + params: Readonly> +) => boolean + export type MethodHandler = ( params: Record, context: RequestContext @@ -101,10 +107,13 @@ export class RelayDispatcher { private readonly requestAborts = new ClientRequestAborts() private readonly publicationLedger = new LegacyRelayPublicationLedger() private pendingRelayRequests = new Map() - private clientDetachListeners = new Set<(clientId: number) => void>() + private clientDetachListeners = new Set< + (clientId: number, cause: PtyConsumerCloseCause) => void + >() private disposeListeners = new Set<() => void>() private legacyCapacityListeners = new Set<() => void>() private clientCapacityListeners = new Map void>>() + private ptyDataPublicationAdmission: PtyDataPublicationAdmission | null = null private publicationTransactionDepth = 0 private deferredLegacyCapacity = false private deferredForcedLegacyCapacity = false @@ -137,8 +146,13 @@ export class RelayDispatcher { } // Why: mark in-flight requests stale on disconnect so a late pty.spawn/fs.watch can't create unowned remote state. - invalidateClient(): void { - this.closeClient(this.primaryClient, new Error('Relay primary client invalidated'), false) + invalidateClient(cause: PtyConsumerCloseCause = 'local'): void { + this.closeClient( + this.primaryClient, + new Error('Relay primary client invalidated'), + false, + cause + ) } // Why: seq numbers and request ids are per SSH channel, so each attached client needs independent protocol state. @@ -153,12 +167,12 @@ export class RelayDispatcher { return client.id } - detachClient(clientId: number): void { + detachClient(clientId: number, cause: PtyConsumerCloseCause = 'local'): void { const client = this.clients.get(clientId) if (!client || client === this.primaryClient) { return } - this.closeClient(client, new Error('Relay client detached'), true) + this.closeClient(client, new Error('Relay client detached'), true, cause) } // Why: a displaced owner must lose its transport whichever client holds it, and the launch channel is @@ -187,7 +201,7 @@ export class RelayDispatcher { this.notificationHandlers.set(method, handler) } - onClientDetached(listener: (clientId: number) => void): () => void { + onClientDetached(listener: (clientId: number, cause: PtyConsumerCloseCause) => void): () => void { this.clientDetachListeners.add(listener) return () => this.clientDetachListeners.delete(listener) } @@ -197,6 +211,20 @@ export class RelayDispatcher { return () => this.disposeListeners.delete(listener) } + // Why single-slot rather than a listener set: admission is a veto, so two registrations would have + // to agree on precedence. One owner (the PTY consumer session) holds it for the dispatcher's life. + registerPtyDataPublicationAdmission(admission: PtyDataPublicationAdmission): () => void { + if (this.ptyDataPublicationAdmission) { + throw new Error('PTY data publication admission is already registered') + } + this.ptyDataPublicationAdmission = admission + return () => { + if (this.ptyDataPublicationAdmission === admission) { + this.ptyDataPublicationAdmission = null + } + } + } + onLegacyPtyCapacity(listener: () => void): () => void { this.legacyCapacityListeners.add(listener) return () => this.legacyCapacityListeners.delete(listener) @@ -273,7 +301,9 @@ export class RelayDispatcher { data: string, limit = data.length ): number { - const clients = this.activeClients() + const clients = this.activeClients().filter((client) => + this.admitsPtyDataPublication(client.id, params) + ) const max = Math.min(data.length, limit) if (clients.length === 0) { return max @@ -323,7 +353,7 @@ export class RelayDispatcher { params } return this.tryPublishToClients( - this.activeClients(), + this.activeClients().filter((client) => this.admitsPtyDataPublication(client.id, params)), msg, options.interactive ? 'interactive' : 'ordinary' ) @@ -338,7 +368,9 @@ export class RelayDispatcher { return false } return this.tryPublishToClients( - this.activeClients().filter((client) => matchesClient(client.id)), + this.activeClients().filter( + (client) => matchesClient(client.id) && this.admitsPtyDataPublication(client.id, params) + ), { jsonrpc: '2.0', method: 'pty.data', params }, options.interactive ? 'interactive' : 'ordinary' ) @@ -353,7 +385,9 @@ export class RelayDispatcher { return false } return this.projectToClients( - this.activeClients().filter((client) => matchesClient(client.id)), + this.activeClients().filter( + (client) => matchesClient(client.id) && this.admitsPtyDataPublication(client.id, params) + ), { jsonrpc: '2.0', method: 'pty.data', params }, options.interactive ? 'interactive' : 'ordinary' ) @@ -373,6 +407,10 @@ export class RelayDispatcher { onSettled({ ok: false, error: new Error('Relay client is not connected') }) return false } + if (!this.admitsPtyDataPublication(clientId, params)) { + onSettled({ ok: false, error: new Error('PTY publication is not admitted') }) + return false + } return this.publishToClient( client, { jsonrpc: '2.0', method: 'pty.data', params }, @@ -539,6 +577,9 @@ export class RelayDispatcher { if (client.closed) { continue } + if (method === 'pty.data' && !this.admitsPtyDataPublication(client.id, params ?? {})) { + continue + } if (method === 'pty.replay') { // Why: replay is never re-sent, so it takes the control lane where overflow is fatal — the // writer closes the client and reconnect reloads history rather than stranding a short buffer. @@ -576,6 +617,9 @@ export class RelayDispatcher { method, ...(params !== undefined ? { params } : {}) } + if (method === 'pty.data' && !this.admitsPtyDataPublication(client.id, params ?? {})) { + return false + } const frameBytes = this.estimateFrameBytes(msg) if (this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) { return true @@ -1047,12 +1091,17 @@ export class RelayDispatcher { const seq = client.nextOutgoingSeq++ return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) } + const isStillAdmitted = + 'method' in msg && msg.method === 'pty.data' + ? () => this.admitsPtyDataPublication(client.id, msg.params ?? {}) + : undefined return client.writer.enqueue( lane, encode, frameBytes, onSettled, - lane === 'control' && controlOverflow === 'reject' + lane === 'control' && controlOverflow === 'reject', + isStillAdmitted ) } @@ -1083,6 +1132,13 @@ export class RelayDispatcher { return Array.from(this.clients.values()).filter((client) => !client.closed) } + private admitsPtyDataPublication( + clientId: number, + params: Readonly> + ): boolean { + return this.ptyDataPublicationAdmission?.(clientId, params) ?? true + } + private activeClientKeys(): string[] { return this.activeClients().map((client) => this.clientKey(client)) } @@ -1269,7 +1325,14 @@ export class RelayDispatcher { } } - private closeClient(client: RelayClient, error: Error, remove: boolean): void { + private closeClient( + client: RelayClient, + error: Error, + remove: boolean, + // Why the default is the cautious one: most closes here are the relay's own doing, and only the + // callers holding real evidence of a peer-side transport end may say so. + cause: PtyConsumerCloseCause = 'local' + ): void { if (client.closed) { return } @@ -1280,7 +1343,7 @@ export class RelayDispatcher { if (remove) { this.clients.delete(client.id) } - this.notifyClientDetached(client.id) + this.notifyClientDetached(client.id, cause) if (remove) { // Only for a client that is gone for good: an invalidated primary is revived by setWrite, and a // frame stranded by its retired sink must stay armed to retry. After the detach fan-out, so a @@ -1329,10 +1392,10 @@ export class RelayDispatcher { } } - private notifyClientDetached(clientId: number): void { + private notifyClientDetached(clientId: number, cause: PtyConsumerCloseCause): void { for (const listener of this.clientDetachListeners) { try { - listener(clientId) + listener(clientId, cause) } catch (err) { process.stderr.write( `[relay] Client detach listener failed: ${err instanceof Error ? err.message : String(err)}\n` diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 23dd28c35cb..9dc4812ef98 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -164,19 +164,23 @@ describe('FsHandler', () => { await expect(dispatcher.callRequest('fs.tempDir')).resolves.toBe(tmpdir()) }) - it('readDir returns sorted entries with directories first', async () => { + it('readDir returns entries directories-first in natural name order', async () => { mkdirSync(path.join(tmpDir, 'subdir')) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - writeFileSync(path.join(tmpDir, 'aaa.txt'), 'world') + for (const name of ['file.txt', '100 - b.txt', '99 - a.txt', '9 - c.txt']) { + writeFileSync(path.join(tmpDir, name), 'x') + } const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as { name: string isDirectory: boolean }[] - expect(result[0].name).toBe('subdir') - expect(result[0].isDirectory).toBe(true) - expect(result.find((e) => e.name === 'file.txt')).toBeDefined() - expect(result.find((e) => e.name === 'aaa.txt')).toBeDefined() + expect(result[0]).toMatchObject({ name: 'subdir', isDirectory: true }) + expect(result.slice(1).map((e) => e.name)).toEqual([ + '9 - c.txt', + '99 - a.txt', + '100 - b.txt', + 'file.txt' + ]) }) it('readDir reports symlinked directories as directories', async () => { diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 9c146af1450..d07e2bf397f 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -5,6 +5,7 @@ import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' +import { sortDirEntries } from '../shared/file-name-sort' import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat // (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. @@ -148,12 +149,7 @@ export class FsHandler { isSymlink: entry.isSymbolicLink() })) ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return sortDirEntries(mapped) } private async readFile(params: Record) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 880e690ee54..60ea5913139 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -24,6 +24,11 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../shared/git-status-line-stats-cache' +import { + readGitBranchLineTotalMergeBaseParam, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import { buildBranchLineTotalInput } from './git-status-branch-line-total' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -74,11 +79,16 @@ export async function getStatusOp( ignoredPaths?: string[] didHitLimit?: boolean statusLength?: number + branchLineTotal?: GitBranchLineTotal }> { const worktreePath = params.worktreePath as string const lineStatsCacheKey = `relay\0${worktreePath}` const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true + // Why: untrusted RPC input spliced into a git argv — only an OID shape may pass. + const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam( + params.branchLineTotalMergeBase + ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) const conflictOperation = await detectConflictOperation(worktreePath) @@ -89,6 +99,8 @@ export async function getStatusOp( let ignoredPaths: string[] = [] let didHitLimit = false let statusLength = 0 + let statusSucceeded = false + let branchLineTotal: GitBranchLineTotal | undefined try { // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. @@ -118,6 +130,7 @@ export async function getStatusOp( ignoredPaths = parser.ignoredPaths statusLength = parser.statusLength didHitLimit = stoppedEarly + statusSucceeded = true const { upstreamName, upstreamAheadBehind } = parser.branch upstreamStatus = upstreamName ? { @@ -173,15 +186,26 @@ export async function getStatusOp( // Why: skip numstat after the limit to avoid reintroducing its cost. if (!didHitLimit) { - await reuseOrRecomputeGitStatusLineStats({ + const branchLineTotalInput = buildBranchLineTotalInput( + git, + worktreePath, + entries, + // Why: a failed scan leaves the untracked list untrustworthy, so the total + // would under-count — omit it rather than publish a confident wrong number. + statusSucceeded ? branchLineTotalMergeBase : undefined, + options.signal + ) + // Why: passed in so the ranged diff runs alongside the per-area numstats, not after them. + ;({ branchLineTotal } = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: params.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(git, worktreePath, entries, options.signal) - }) + recompute: () => attachLineStats(git, worktreePath, entries, options.signal), + ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) + })) } else { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } @@ -200,7 +224,8 @@ export async function getStatusOp( branch, upstreamStatus, ...(includeIgnored ? { ignoredPaths } : {}), - ...(didHitLimit ? { didHitLimit: true, statusLength } : {}) + ...(didHitLimit ? { didHitLimit: true, statusLength } : {}), + ...(branchLineTotal ? { branchLineTotal } : {}) } } diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 6674500c6f9..0abc9f5d5be 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -89,6 +89,7 @@ import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' import { streamRelayGitStdout } from './git-stdout-stream' const execFileAsync = promisify(execFile) @@ -298,6 +299,7 @@ export class GitHandler { private clearGitMutationReadCaches(): void { this.gitDiffReadDedupe.clear() + invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache(this.submodulePathsCache) } diff --git a/src/relay/git-status-branch-line-total.test.ts b/src/relay/git-status-branch-line-total.test.ts new file mode 100644 index 00000000000..ceb04351e07 --- /dev/null +++ b/src/relay/git-status-branch-line-total.test.ts @@ -0,0 +1,475 @@ +/** + * Relay-side contract for the status pass's branch line total: the chip is an + * exact number or absent, and costs nothing when nobody asked for it. + */ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import type { GitExec } from './git-handler-ops' +import { getStatusOp } from './git-handler-status-ops' +import type { RelayGitStreamExec } from './git-stdout-stream' +import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' + +type GitCall = Parameters + +const execFileAsync = promisify(execFile) +const MERGE_BASE = '0123456789abcdef0123456789abcdef01234567' +const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98' +// Untracked additions ride on top of the ranged diff, so the fixture needs a real file. +const UNTRACKED_FILE = 'notes.md' +const UNTRACKED_LINES = 4 +const STATUS_OUTPUT = [ + '# branch.oid 1111111111111111111111111111111111111111', + '# branch.head (detached)', + '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', + `? ${UNTRACKED_FILE}` +].join('\n') + +function isRangedNumstat(args: string[]): boolean { + return args.includes('diff') && args.includes('--numstat') && args.includes('-z') +} + +function rangedDiffCalls(calls: readonly GitCall[]): string[][] { + return calls.map(([args]) => args).filter((args) => isRangedNumstat(args)) +} + +function streamGitFromCapture(git: GitExec): RelayGitStreamExec { + return async (args, cwd, options) => { + const { stdout } = await git(args, cwd, { + disableOptionalLocks: options.disableOptionalLocks, + signal: options.signal + }) + return { stoppedEarly: options.onStdout(stdout) === true } + } +} + +/** Mock host; the fixture-repo cases below run real git instead. */ +function createMockGit(overrides: { + status?: string + ranged?: () => Promise<{ stdout: string; stderr: string }> + areaNumstat?: string +}) { + return vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: overrides.status ?? STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) +} + +const realGitExec: GitExec = async (args, cwd, opts) => { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.timeout ? { timeout: opts.timeout } : {}) + }) + return { stdout, stderr } +} + +function runFixtureGit(repo: string, args: string[]): string { + return execFileSync( + 'git', + [ + '-c', + 'user.email=test@test.com', + '-c', + 'user.name=Test', + '-c', + 'commit.gpgSign=false', + ...args + ], + { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +/** + * Fork point → working tree: +3 tracked (2 committed, 1 unstaged), -2 from the + * file the branch deleted, +4 untracked. The pure rename contributes nothing. + */ +async function seedBranchFixture(repo: string): Promise { + await fs.rm(path.join(repo, UNTRACKED_FILE), { force: true }) + execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\n') + await fs.writeFile(path.join(repo, 'doomed.txt'), 'x\ny\n') + await fs.writeFile(path.join(repo, 'old-name.txt'), 'stable\n') + runFixtureGit(repo, ['add', '.']) + runFixtureGit(repo, ['commit', '-m', 'base']) + const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) + + runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\n') + await fs.rm(path.join(repo, 'doomed.txt')) + runFixtureGit(repo, ['add', '-A']) + runFixtureGit(repo, ['commit', '-m', 'branch commit']) + + runFixtureGit(repo, ['mv', 'old-name.txt', 'new-name.txt']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\nf\n') + await fs.writeFile(path.join(repo, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + return mergeBase +} + +describe('getStatusOp branch line total', () => { + let tmpDir: string + + beforeEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-branch-line-total-')) + await fs.writeFile(path.join(tmpDir, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + }) + + afterEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it('runs no ranged diff and returns no total when the merge base param is absent', async () => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) + + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + // The per-area numstats the CHANGES rows need still ran, so this is a + // zero-cost omission rather than a disabled status pass. + expect(result.entries).toContainEqual( + expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) + ) + }) + + it('sums the ranged diff and untracked additions against a real fixture repo', async () => { + const mergeBase = await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: mergeBase + }) + + expect(result.branchLineTotal).toEqual({ + added: 3 + UNTRACKED_LINES, + removed: 2, + mergeBase + }) + expect(rangedDiffCalls(git.mock.calls)).toEqual([ + ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] + ]) + }) + + it('omits the total when the status listing hit its limit', async () => { + const manyEntries = Array.from( + { length: 6 }, + (_, index) => `1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt` + ).join('\n') + const git = createMockGit({ status: manyEntries }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + limit: 3, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.didHitLimit).toBe(true) + // The untracked list is truncated, so any total would silently under-count. + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + it('omits the total, never zero, when the ranged diff fails', async () => { + const git = createMockGit({ + ranged: () => Promise.reject(new Error('fatal: bad object 0123456789abcdef')) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // A failed ranged diff must not take the per-file rows down with it. + expect(result.entries).toContainEqual(expect.objectContaining({ path: 'src/a.ts', added: 3 })) + }) + + it('omits the total, never zero, for a well-formed but unknown merge-base oid', async () => { + await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + }) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.entries.length).toBeGreaterThan(0) + }) + + it('omits the total when the ranged diff exceeds its time budget', async () => { + const git = createMockGit({ + ranged: () => { + const error: Error & { killed?: boolean } = new Error('spawn git ETIMEDOUT') + error.killed = true + return Promise.reject(error) + } + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // The budget is handed to the subprocess rather than raced on a wall clock. + const rangedOptions = git.mock.calls.find(([args]) => isRangedNumstat(args))?.[2] + expect(rangedOptions).toMatchObject({ disableOptionalLocks: true, timeout: expect.any(Number) }) + }) + + it('omits the total when the status scan itself failed', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + throw new Error('fatal: not a git repository') + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.entries).toEqual([]) + // A failed scan yields an empty untracked list, which would read as "no + // untracked additions" rather than "unknown". + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + // Relay params are an untyped bag, so the merge base must be proven to be an + // object name before it can be spliced into an argv. + it.each([ + ['a flag-shaped value', '--upload-pack=x'], + ['a rev name', 'HEAD'], + ['a ref path', 'refs/heads/main'], + ['a range', `${MERGE_BASE}..HEAD`], + ['a number', 123], + ['null', null], + ['an empty string', ''] + ])('rejects %s before it reaches a git argv', async (_label, value) => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: value + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + for (const [args] of git.mock.calls) { + expect(args).not.toContain(String(value)) + } + }) + + it('answers a rejected merge base with the byte-identical no-merge-base response', async () => { + const gitWithout = createMockGit({}) + const withoutParam = await getStatusOp(gitWithout, streamGitFromCapture(gitWithout), { + worktreePath: tmpDir + }) + clearGitStatusLineStatsCache() + const gitRejected = createMockGit({}) + const rejectedParam = await getStatusOp(gitRejected, streamGitFromCapture(gitRejected), { + worktreePath: tmpDir, + branchLineTotalMergeBase: '--upload-pack=x' + }) + + expect(JSON.stringify(rejectedParam)).toBe(JSON.stringify(withoutParam)) + expect(gitRejected.mock.calls.map(([args]) => args)).toEqual( + gitWithout.mock.calls.map(([args]) => args) + ) + }) + + it('rejects an aborted scan instead of resolving a partial total', async () => { + const controller = new AbortController() + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + controller.abort() + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await expect( + getStatusOp( + git, + streamGitFromCapture(git), + { worktreePath: tmpDir, branchLineTotalMergeBase: MERGE_BASE }, + { signal: controller.signal } + ) + ).rejects.toThrow(/abort/i) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('reuses the cached total instead of re-running the ranged diff', async () => { + const git = createMockGit({}) + + const first = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const reused = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE, + reuseLineStats: true + }) + + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE + }) + expect(reused.branchLineTotal).toEqual(first.branchLineTotal) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('coalesces concurrent status passes onto one ranged diff', async () => { + // The renderer's own in-flight refs only dedupe one renderer; a second + // window or an fs-watcher burst must not run the ranged diff twice. + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const [first, second] = await Promise.all([ + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }), + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + ]) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(first.branchLineTotal).toEqual(second.branchLineTotal) + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE + }) + }) + + it('recomputes rather than reusing a total measured against another fork point', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const moved = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: OTHER_MERGE_BASE, + reuseLineStats: true + }) + + expect(moved.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: OTHER_MERGE_BASE + }) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2) + }) + + // Rule 1 of docs/reference/remote-wire-compatibility.md: a new optional field + // is safe only while every reader survives its absence. + describe('wire compatibility', () => { + it('drops the key from the wire payload so a reader sees undefined, not 0 or NaN', async () => { + const git = createMockGit({ ranged: () => Promise.reject(new Error('fatal: bad object')) }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + // An old server's payload and a new server's "not known exact" payload are + // the same thing on the wire: no key at all. + const overTheWire = JSON.parse(JSON.stringify(result)) as { + branchLineTotal?: { added: number; removed: number } + } + + expect(Object.hasOwn(overTheWire, 'branchLineTotal')).toBe(false) + expect(overTheWire.branchLineTotal).toBeUndefined() + expect(overTheWire.branchLineTotal?.added).toBeUndefined() + expect(overTheWire.branchLineTotal?.removed).toBeUndefined() + }) + + it('keeps the new request param out of the status argv an old server would run', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + const statusArgs = git.mock.calls + .map(([args]) => args) + .filter((args) => args.includes('status')) + expect(statusArgs).toHaveLength(1) + expect(statusArgs[0]).not.toContain(MERGE_BASE) + }) + + it('publishes finite counts when the ranged diff reports only binaries', async () => { + const git = createMockGit({ + ranged: () => Promise.resolve({ stdout: '-\t-\tlogo.png\n', stderr: '' }) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.branchLineTotal).toEqual({ + added: UNTRACKED_LINES, + removed: 0, + mergeBase: MERGE_BASE + }) + expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true) + expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true) + }) + }) +}) diff --git a/src/relay/git-status-branch-line-total.ts b/src/relay/git-status-branch-line-total.ts new file mode 100644 index 00000000000..c2fe6556a0e --- /dev/null +++ b/src/relay/git-status-branch-line-total.ts @@ -0,0 +1,44 @@ +/** + * Relay-side wiring for the status pass's branch line total. + * Why: separate file so git-handler-status-ops.ts stays under oxlint max-lines (300). + */ +import { + computeGitBranchLineTotal, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import type { GitExec } from './git-handler-ops' + +/** Undefined when no valid merge base was requested, which keeps the ranged diff entirely off. */ +export function buildBranchLineTotalInput( + git: GitExec, + worktreePath: string, + entries: Record[], + mergeBase: string | undefined, + signal?: AbortSignal +): { mergeBase: string; compute: () => Promise } | undefined { + if (!mergeBase) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Matches the `relay\0` line-stats cache-key convention. + hostKey: 'relay', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path as string), + runDiffNumstat: (args, diffSignal) => + git(args, worktreePath, { + // Why: a working-tree diff must not take index.lock away from terminal Git. + disableOptionalLocks: true, + signal: diffSignal, + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then(({ stdout }) => stdout), + ...(signal ? { signal } : {}) + }) + } +} diff --git a/src/relay/pty-handler-source-publication.test.ts b/src/relay/pty-handler-source-publication.test.ts index 48a5f74904f..04e45d2b384 100644 --- a/src/relay/pty-handler-source-publication.test.ts +++ b/src/relay/pty-handler-source-publication.test.ts @@ -221,12 +221,18 @@ describe('PtyHandler negotiated source publication', () => { expect(exitFrames()).toHaveLength(1) }) - function attachSubscriber(): Buffer[] { + async function attachSubscriber( + holdDataSettlement?: (settle: (result: SinkWriteSettlement) => void) => boolean + ): Promise { const subscriberWrites: Buffer[] = [] - dispatcher.attachClient( + const clientId = dispatcher.attachClient( (data, settle) => { subscriberWrites.push(Buffer.from(data)) - if (notification(data)?.method === 'pty.exit') { + const frame = notification(data) + if (frame?.method === 'pty.data' && holdDataSettlement?.(settle)) { + return true + } + if (frame?.method === 'pty.exit') { // Why: real sockets never settle inside write(); see the primary sink above. queueMicrotask(() => settle({ ok: true })) return true @@ -234,8 +240,18 @@ describe('PtyHandler negotiated source publication', () => { settle({ ok: true }) return true }, - { supportsWriteCallback: true } + { supportsWriteCallback: true }, + endpointIdentity ) + dispatcher.feedClient( + clientId, + requestFrame(20, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'legacy-subscriber', + requestedRole: 'subscriber' + }) + ) + await vi.advanceTimersByTimeAsync(0) return subscriberWrites } @@ -248,7 +264,7 @@ describe('PtyHandler negotiated source publication', () => { it('never re-delivers the exit to subscribers when a cancel retires the record', async () => { await spawn({}) const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const subscriberWrites = attachSubscriber() + const subscriberWrites = await attachSubscriber() dataCallback!('prompt') await vi.advanceTimersByTimeAsync(8) @@ -293,6 +309,13 @@ describe('PtyHandler negotiated source publication', () => { await spawn({}) const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! await cancelSourceDelivery(spawnResult) + const subscriberWrites = await attachSubscriber((settle) => { + if (!holdDataSettlements) { + return false + } + heldDataSettlements.push(settle) + return true + }) holdDataSettlements = true dataCallback!('first') @@ -311,7 +334,7 @@ describe('PtyHandler negotiated source publication', () => { heldDataSettlements[0]({ ok: true }) await vi.advanceTimersByTimeAsync(8) - const frames = writes + const frames = subscriberWrites .map(notification) .filter( (frame): frame is Notification => @@ -351,7 +374,7 @@ describe('PtyHandler negotiated source publication', () => { await spawn({}) const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! const id = String(spawnResult.id) - const subscriberWrites = attachSubscriber() + const subscriberWrites = await attachSubscriber() const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) const publishOwnerExit = vi .spyOn(dispatcher, 'tryNotifyPtyExitToClient') @@ -381,7 +404,7 @@ describe('PtyHandler negotiated source publication', () => { await spawn({}) const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! const id = String(spawnResult.id) - const subscriberWrites = attachSubscriber() + const subscriberWrites = await attachSubscriber() const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) const settleOwnerExit = vi.spyOn(adapter, 'settleExitPublication').mockImplementation(() => { throw new Error('exit settlement failed') @@ -408,7 +431,7 @@ describe('PtyHandler negotiated source publication', () => { it('lets a retired record re-target its own exit instead of broadcasting a duplicate', async () => { await spawn({}) const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! - const subscriberWrites = attachSubscriber() + const subscriberWrites = await attachSubscriber() const publishExitAfterRetire = vi.fn(() => true) handler.setSourcePublication(stubPublication({ accepts: () => false, publishExitAfterRetire })) @@ -680,20 +703,50 @@ describe('PtyHandler negotiated source publication', () => { await spawn({}) const detached: number[] = [] const healthyWrites: Buffer[] = [] + let saturateSubscriber = false dispatcher.onClientDetached((clientId) => detached.push(clientId)) - const saturatedId = dispatcher.attachClient(() => false, { - supportsWriteCallback: true, - writableLength: () => 16 * 1024, - writableHighWaterMark: () => 4 * 1024 * 1024 - }) + const saturatedId = dispatcher.attachClient( + (_data, settle) => { + if (saturateSubscriber) { + return false + } + settle({ ok: true }) + return true + }, + { + supportsWriteCallback: true, + writableLength: () => 16 * 1024, + writableHighWaterMark: () => 4 * 1024 * 1024 + }, + endpointIdentity + ) const healthyId = dispatcher.attachClient( (data, settle) => { healthyWrites.push(Buffer.from(data)) settle({ ok: true }) return true }, - { supportsWriteCallback: true } + { supportsWriteCallback: true }, + endpointIdentity ) + dispatcher.feedClient( + saturatedId, + requestFrame(20, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'saturated-subscriber', + requestedRole: 'subscriber' + }) + ) + dispatcher.feedClient( + healthyId, + requestFrame(21, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'healthy-subscriber', + requestedRole: 'subscriber' + }) + ) + await vi.advanceTimersByTimeAsync(0) + saturateSubscriber = true const payload = 's'.repeat(16 * 1024) let admitted = 0 while ( diff --git a/src/relay/relay-pty-consumer-owner-displacement.test.ts b/src/relay/relay-pty-consumer-owner-displacement.test.ts index 89059119f5f..155abc542f0 100644 --- a/src/relay/relay-pty-consumer-owner-displacement.test.ts +++ b/src/relay/relay-pty-consumer-owner-displacement.test.ts @@ -121,7 +121,7 @@ describe('relay PTY consumer owner displacement', () => { expect(adapter.deliveryMode(reconnectClientId)).toBe('source-owner') // Why: revocation is what bounds the takeover — the stale owner cannot open or drive deliveries again // even though its socket never closed. - expect(adapter.deliveryMode(1)).toBe('subscriber') + expect(adapter.deliveryMode(1)).toBe('unadmitted') expect(adapter.openDelivery(1, 'pty-2', 'incarnation-1')).toBeNull() expect(detached).toContain(1) expect(closeStaleTransport).toHaveBeenCalledOnce() @@ -244,7 +244,7 @@ describe('relay PTY consumer owner displacement', () => { grantSettlement!({ ok: false, error: new Error('reconnect socket closed mid-response') }) expect(adapter.deliveryMode(1)).toBe('source-owner') - expect(adapter.deliveryMode(reconnectClientId)).toBe('subscriber') + expect(adapter.deliveryMode(reconnectClientId)).toBe('unadmitted') // Why: the rolled-back attempt must leave the incumbent's lease reclaimable by the next reconnect. const retryWrites: Buffer[] = [] @@ -275,7 +275,7 @@ describe('relay PTY consumer owner displacement', () => { role: 'session-owner', ownerGeneration: 3 }) - expect(adapter.deliveryMode(1)).toBe('subscriber') + expect(adapter.deliveryMode(1)).toBe('unadmitted') expect(detached).toContain(1) }) @@ -349,7 +349,7 @@ describe('relay PTY consumer owner displacement', () => { code: PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR }) expect(adapter.deliveryMode(firstReconnectClientId)).toBe('source-owner') - expect(adapter.deliveryMode(retryClientId)).toBe('subscriber') + expect(adapter.deliveryMode(retryClientId)).toBe('unadmitted') dispatcher.detachClient(firstReconnectClientId) dispatcher.feedClient( diff --git a/src/relay/relay-pty-publication-admission.test.ts b/src/relay/relay-pty-publication-admission.test.ts new file mode 100644 index 00000000000..d1cb9551cfe --- /dev/null +++ b/src/relay/relay-pty-publication-admission.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + RelayDispatcher, + type RelayClientSessionIdentity, + type RelayClientSinkOptions, + type SinkWriteSettlement +} from './dispatcher' +import { encodeJsonRpcFrame, MessageType } from './protocol' +import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const endpointIdentity: RelayClientSessionIdentity = { + principal: 'endpoint-principal', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' +} + +type RpcMessage = { + id?: number + method?: string + params?: Record + result?: Record +} + +function message(buffer: Buffer): RpcMessage | null { + if (buffer[0] !== MessageType.Regular) { + return null + } + const length = buffer.readUInt32BE(9) + return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) +} + +function openFrame( + id: number, + requestedRole: 'session-owner' | 'subscriber', + outputFlowControl = false +): Buffer { + return encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id, + method: 'pty.openClient', + params: { + protocolVersion: 1, + clientInstanceId: `client-${id}`, + requestedRole, + ...(outputFlowControl + ? { + capabilities: { + outputFlowControl: { versions: [1], requestedWindowSu: 16 } + } + } + : {}) + } + }, + id, + 0 + ) +} + +async function flushRequests(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +class SaturatedSink { + readonly writes: Buffer[] = [] + private readonly drainWaiters = new Set<() => void>() + private writableBytes = 0 + saturateNext = false + + readonly options: RelayClientSinkOptions = { + supportsWriteCallback: true, + writableLength: () => this.writableBytes, + writableHighWaterMark: () => 8 * 1024 * 1024, + waitWriteDrain: (callback) => { + this.drainWaiters.add(callback) + return () => this.drainWaiters.delete(callback) + } + } + + write = (data: Buffer, onSettled: (result: SinkWriteSettlement) => void): boolean => { + this.writes.push(Buffer.from(data)) + this.writableBytes += data.length + onSettled({ ok: true }) + if (!this.saturateNext) { + return true + } + this.saturateNext = false + return false + } + + drain(): void { + this.writableBytes = 0 + for (const callback of Array.from(this.drainWaiters)) { + callback() + } + } +} + +describe('relay PTY publication admission', () => { + let dispatcher: RelayDispatcher | null = null + + afterEach(() => { + dispatcher?.dispose() + dispatcher = null + }) + + it('retires queued legacy data when the source-credit grant response overtakes it', async () => { + const sink = new SaturatedSink() + dispatcher = new RelayDispatcher(sink.write, sink.options, endpointIdentity) + sink.saturateNext = true + dispatcher.notify('test.blocker') + const settled = vi.fn<(result: SinkWriteSettlement) => void>() + const legacyData = 'x'.repeat(1024 * 1024 + 128) + + expect(dispatcher.tryNotifyPtyDataToClient(1, { id: 'pty-1', data: legacyData }, settled)).toBe( + true + ) + expect(dispatcher.legacyRetentionBelowLowWater).toBe(false) + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + expect(adapter.deliveryMode(1)).toBe('unadmitted') + dispatcher.feed(openFrame(1, 'session-owner', true)) + await flushRequests() + + sink.drain() + + expect(sink.writes.map(message).some((entry) => entry?.method === 'pty.data')).toBe(false) + expect(sink.writes.map(message).find((entry) => entry?.id === 1)?.result).toMatchObject({ + role: 'session-owner' + }) + expect(settled).toHaveBeenCalledOnce() + expect(settled.mock.calls[0][0]).toMatchObject({ ok: false }) + expect(dispatcher.legacyRetentionBelowLowWater).toBe(true) + }) + + it('does not publish legacy data to a source-credit owner before PTY attachment', async () => { + const writes: Buffer[] = [] + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + dispatcher.feed(openFrame(1, 'session-owner', true)) + await flushRequests() + + expect(adapter.deliveryMode(1)).toBe('source-owner') + expect(dispatcher.tryNotifyPtyData({ id: 'pty-1', data: 'legacy' })).toBe(true) + + expect(writes.map(message).filter((entry) => entry?.method === 'pty.data')).toEqual([]) + }) + + it('publishes current source output to the admitted source-credit owner', async () => { + const writes: Buffer[] = [] + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + const publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) + dispatcher.feed(openFrame(1, 'session-owner', true)) + await flushRequests() + const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] + + expect( + publication.activate('pty-1', 'incarnation-1', { + clientId: 1, + isStale: () => false, + sessionIdentity: endpointIdentity, + onResponseSettled: (callback) => activationSettlements.push(callback) + }) + ).toBe('opened') + activationSettlements[0]({ ok: true }) + expect(publication.publish('pty-1', { data: 'current' }, false)).toBe(true) + + const data = writes.map(message).find((entry) => entry?.method === 'pty.data')?.params + expect(data).toMatchObject({ + id: 'pty-1', + data: 'current', + clientGeneration: 1, + ownerGeneration: 1, + ptyIncarnation: 'incarnation-1' + }) + expect(data?.deliveryToken).toEqual(expect.any(String)) + }) + + it('publishes legacy output to an admitted subscriber', async () => { + const writes: Buffer[] = [] + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + dispatcher.feed(openFrame(1, 'subscriber')) + await flushRequests() + + expect(adapter.deliveryMode(1)).toBe('subscriber') + expect(dispatcher.tryNotifyPtyData({ id: 'pty-1', data: 'legacy' })).toBe(true) + + expect(writes.map(message).find((entry) => entry?.method === 'pty.data')?.params).toMatchObject( + { id: 'pty-1', data: 'legacy' } + ) + }) +}) diff --git a/src/relay/relay-pty-source-output.ts b/src/relay/relay-pty-source-output.ts index bbc57d1c643..0f6ea798a9b 100644 --- a/src/relay/relay-pty-source-output.ts +++ b/src/relay/relay-pty-source-output.ts @@ -58,7 +58,10 @@ export function projectPtySourceOutputToLegacy( interactive: boolean ): boolean { return dispatcher.projectPtyDataToMatchingClients( - (clientId) => session.deliveryMode(clientId) !== 'source-owner', + (clientId) => { + const mode = session.deliveryMode(clientId) + return mode === 'legacy-owner' || mode === 'subscriber' + }, { id, data: output.data, diff --git a/src/relay/relay-pty-source-publication.test.ts b/src/relay/relay-pty-source-publication.test.ts index 1d30e4e8540..af75613debd 100644 --- a/src/relay/relay-pty-source-publication.test.ts +++ b/src/relay/relay-pty-source-publication.test.ts @@ -223,14 +223,24 @@ describe('RelayPtySourcePublication', () => { it('keeps mixed legacy and V1 clients on distinct frame authority', async () => { const harness = await createHarness(8) const legacyWrites: Buffer[] = [] - dispatcher!.attachClient( + const legacyClientId = dispatcher!.attachClient( (data, onSettled) => { legacyWrites.push(Buffer.from(data)) onSettled({ ok: true }) return true }, - { supportsWriteCallback: true } + { supportsWriteCallback: true }, + endpointIdentity ) + dispatcher!.feedClient( + legacyClientId, + requestFrame(2, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'legacy-client', + requestedRole: 'subscriber' + }) + ) + await flushRequests() harness.publication.publish('pty-1', { data: 'data' }, false) @@ -289,10 +299,15 @@ describe('RelayPtySourcePublication', () => { const saturatedWrites: Buffer[] = [] const healthyWrites: Buffer[] = [] const heldSettlements: ((result: SinkWriteSettlement) => void)[] = [] + let saturateSubscriber = false dispatcher!.onClientDetached((clientId) => detached.push(clientId)) const saturatedId = dispatcher!.attachClient( (data, onSettled) => { saturatedWrites.push(Buffer.from(data)) + if (!saturateSubscriber) { + onSettled({ ok: true }) + return true + } heldSettlements.push(onSettled) return false }, @@ -300,7 +315,8 @@ describe('RelayPtySourcePublication', () => { supportsWriteCallback: true, writableLength: () => 128 * 1024, writableHighWaterMark: () => 4 * 1024 * 1024 - } + }, + endpointIdentity ) const healthyId = dispatcher!.attachClient( (data, onSettled) => { @@ -308,8 +324,27 @@ describe('RelayPtySourcePublication', () => { onSettled({ ok: true }) return true }, - { supportsWriteCallback: true } + { supportsWriteCallback: true }, + endpointIdentity ) + dispatcher!.feedClient( + saturatedId, + requestFrame(2, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'saturated-subscriber', + requestedRole: 'subscriber' + }) + ) + dispatcher!.feedClient( + healthyId, + requestFrame(3, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'healthy-subscriber', + requestedRole: 'subscriber' + }) + ) + await flushRequests() + saturateSubscriber = true const saturatedPayload = 's'.repeat(128 * 1024) let admitted = 0 while ( @@ -328,7 +363,9 @@ describe('RelayPtySourcePublication', () => { expect(detached).toEqual([saturatedId]) expect(detached).not.toContain(healthyId) - expect(saturatedWrites).toHaveLength(1) + expect( + saturatedWrites.map(notification).filter((frame) => frame?.method === 'pty.data') + ).toHaveLength(1) expect(heldSettlements).toHaveLength(1) expect( healthyWrites.map(notification).filter((frame) => frame?.method === 'pty.data') diff --git a/src/relay/relay-pty-source-publication.ts b/src/relay/relay-pty-source-publication.ts index b21e8a79cf2..bad396b5b68 100644 --- a/src/relay/relay-pty-source-publication.ts +++ b/src/relay/relay-pty-source-publication.ts @@ -71,7 +71,7 @@ export class RelayPtySourcePublication { } const mode = this.session.deliveryMode(context.clientId) let current = this.deliveries.get(id) - if (mode === 'subscriber') { + if (mode === 'unadmitted' || mode === 'subscriber') { this.sender.releaseRotationFence(current) return false } diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 4169c4fc449..1da1dd70b5d 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -1008,7 +1008,9 @@ async function main(): Promise { const clientId = socketClients.get(sock) socketClients.delete(sock) if (clientId !== undefined) { - dispatcher.detachClient(clientId) + // Why 'peer-closed' only here: the socket itself ended, which is the one signal that + // actually says the client is gone rather than merely slow. + dispatcher.detachClient(clientId, 'peer-closed') } relayLogLine(`[relay] Socket client closed (clients=${socketClients.size})`) if (!stdoutAlive && socketClients.size === 0) { @@ -1159,7 +1161,7 @@ async function main(): Promise { process.stdout.on('error', () => { stdoutAlive = false flushStdoutDrainWaiters() - dispatcher.invalidateClient() + dispatcher.invalidateClient('peer-closed') }) function startGrace(reason: string, options?: { retryDeferredShutdown?: boolean }): void { @@ -1214,7 +1216,7 @@ async function main(): Promise { // Why: stdin close means the SSH channel is gone; mark stdout dead so its write callback no-ops instead of hitting a dead pipe. stdoutAlive = false flushStdoutDrainWaiters() - dispatcher.invalidateClient() + dispatcher.invalidateClient('peer-closed') if (socketClients.size === 0) { startGrace('stdin ended') } @@ -1223,7 +1225,7 @@ async function main(): Promise { process.stdin.on('error', () => { stdoutAlive = false flushStdoutDrainWaiters() - dispatcher.invalidateClient() + dispatcher.invalidateClient('peer-closed') if (socketClients.size === 0) { startGrace('stdin error') } diff --git a/src/relay/ssh-pty-consumer-session-adapter.test.ts b/src/relay/ssh-pty-consumer-session-adapter.test.ts index a0f040814ed..7c38799b876 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.test.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { PTY_CONSUMER_OWNER_GRACE_MS } from '../shared/pty-consumer-session' +import { + PTY_CONSUMER_OWNER_GRACE_MS, + PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR, + PTY_CONSUMER_OWNER_HELD_GRACE_FLOOR_MS, + PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR +} from '../shared/pty-consumer-session' import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' import { encodeJsonRpcFrame, MessageType } from './protocol' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' @@ -29,10 +34,18 @@ function openFrame(id: number, overrides: Record = {}): Buffer ) } -function responseResult(buffer: Buffer): Record { +function responsePayload(buffer: Buffer): Record { expect(buffer[0]).toBe(MessageType.Regular) const length = buffer.readUInt32BE(9) - return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')).result + return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) +} + +function responseResult(buffer: Buffer): Record { + return responsePayload(buffer).result as Record +} + +function responseError(buffer: Buffer): Record { + return responsePayload(buffer).error as Record } async function flushRequests(): Promise { @@ -80,9 +93,14 @@ describe('SshPtyConsumerSessionAdapter', () => { expect(responseResult(firstWrites[0])).toMatchObject({ role: 'session-owner', - ownerGeneration: 1 + ownerGeneration: 1, + resumed: false + }) + // Why a coded refusal reaches the wire: the dispatcher transports code and message only, so the + // competitor has to be able to tell "still settling" from "held" without parsing prose. + expect(responseError(secondWrites[0])).toMatchObject({ + code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR }) - expect(responseResult(secondWrites[0])).toMatchObject({ role: 'subscriber' }) firstSettlements[0]({ ok: true }) }) @@ -118,6 +136,79 @@ describe('SshPtyConsumerSessionAdapter', () => { }) }) + it.each([['peer-closed'], ['local']] as const)( + 'shortens a refused owner grace only for a %s detach', + async (cause) => { + // Why only Date: flushRequests rides setImmediate, which fake timers would otherwise capture. + vi.useFakeTimers({ toFake: ['Date'] }) + dispatcher = new RelayDispatcher( + (_data, onSettled) => { + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + + const ownerWrites: Buffer[] = [] + const ownerId = dispatcher.attachClient( + (data, onSettled) => { + ownerWrites.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + dispatcher.feedClient(ownerId, openFrame(1)) + await flushRequests() + expect(responseResult(ownerWrites[0])).toMatchObject({ role: 'session-owner' }) + + dispatcher.detachClient(ownerId, cause) + + const rivalWrites: Buffer[] = [] + const rivalId = dispatcher.attachClient( + (data, onSettled) => { + rivalWrites.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + { ...endpointIdentity, principal: 'competitor' } + ) + // The first refusal is what applies the floor, so it has to happen before the clock moves. + dispatcher.feedClient(rivalId, openFrame(2)) + await flushRequests() + expect(responseError(rivalWrites[0])).toMatchObject({ + code: PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR + }) + + vi.setSystemTime(Date.now() + PTY_CONSUMER_OWNER_HELD_GRACE_FLOOR_MS + 1) + const retryId = dispatcher.attachClient( + (data, onSettled) => { + rivalWrites.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + { ...endpointIdentity, principal: 'competitor' } + ) + dispatcher.feedClient(retryId, openFrame(3)) + await flushRequests() + + // Why this pair is the whole point of the cause: a socket that ended is evidence the owner is + // gone, and a queue the relay overran is not. Only the first may cost the incumbent its claim. + if (cause === 'peer-closed') { + expect(responseResult(rivalWrites[1])).toMatchObject({ role: 'session-owner' }) + } else { + expect(responseError(rivalWrites[1])).toMatchObject({ + code: PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR + }) + } + } + ) + it('rejects an unproved constructor stream as an owner principal', async () => { const writes: Buffer[] = [] dispatcher = new RelayDispatcher((data, onSettled) => { diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts index b28082aa7bc..83e61f17fa3 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.ts @@ -2,8 +2,7 @@ import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION, PtyConsumerSession, type PtyConsumerSessionAdmission, - type PtyConsumerSessionGrant, - type PtyConsumerSessionHello + type PtyConsumerSessionGrant } from '../shared/pty-consumer-session' import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../shared/pty-source-credit-contract' import type { @@ -12,63 +11,18 @@ import type { PtySourceSpan, PtySourceTransform } from '../shared/pty-source-credit-contract' -import type { RelayClientSessionIdentity, RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from './dispatcher' import type { PtySourceSendReservation } from './pty-source-credit-ledger' import { SshPtySourceCreditAdapter } from './ssh-pty-source-credit-adapter' +import { + admitsSshPtyDataPublication, + sshPtyDeliveryMode, + type SshPtyDeliveryMode +} from './ssh-pty-data-publication-admission' +import { parseOpenClientParams, requireIdentity } from './ssh-pty-open-client-request' export const SSH_PTY_OPEN_CLIENT_METHOD = 'pty.openClient' -type OpenClientParams = PtyConsumerSessionHello & { - protocolVersion: number -} - -function parseOpenClientParams(params: Record): OpenClientParams { - const resume = - typeof params.resume === 'object' && params.resume !== null - ? (params.resume as Record) - : undefined - const capabilities = - typeof params.capabilities === 'object' && params.capabilities !== null - ? (params.capabilities as Record) - : undefined - const outputFlowControl = - typeof capabilities?.outputFlowControl === 'object' && capabilities.outputFlowControl !== null - ? (capabilities.outputFlowControl as Record) - : undefined - return { - protocolVersion: Number(params.protocolVersion), - clientInstanceId: String(params.clientInstanceId ?? ''), - requestedRole: String(params.requestedRole ?? '') as PtyConsumerSessionHello['requestedRole'], - ...(resume - ? { - resume: { - ownerGeneration: Number(resume.ownerGeneration), - ownerLease: String(resume.ownerLease ?? '') - } - } - : {}), - ...(outputFlowControl - ? { - capabilities: { - outputFlowControl: { - versions: Array.isArray(outputFlowControl.versions) - ? outputFlowControl.versions.map(Number) - : [], - requestedWindowSu: Number(outputFlowControl.requestedWindowSu) - } - } - } - : {}) - } -} - -function requireIdentity(context: RequestContext): RelayClientSessionIdentity { - if (!context.sessionIdentity) { - throw new Error('SSH PTY consumer transport identity is unavailable') - } - return context.sessionIdentity -} - export class SshPtyConsumerSessionAdapter { private readonly session: PtyConsumerSession private readonly sourceCredit: SshPtySourceCreditAdapter @@ -92,15 +46,26 @@ export class SshPtyConsumerSessionAdapter { serverBuildId, outputFlowControl: { versions: [1], maxWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } }) + // Why the admission is consulted again at drain time (see isStillAdmitted): a frame can sit + // queued behind a saturated socket long enough for the grant or the delivery to be retired, and + // publishing it then hands the client output from an owner it no longer is. + dispatcher.registerPtyDataPublicationAdmission((clientId, params) => + admitsSshPtyDataPublication( + this.session.activeGrant(String(clientId)), + params, + this.sourceCredit + ) + ) dispatcher.onRequest(SSH_PTY_OPEN_CLIENT_METHOD, (params, context) => this.openClient(params, context) ) - dispatcher.onClientDetached((clientId) => { - const grant = this.session.activeGrant(String(clientId)) + dispatcher.onClientDetached((clientId, cause) => { + const connectionKey = String(clientId) + const grant = this.session.activeGrant(connectionKey) if (grant) { this.clearPausedForGrant(grant) } - this.session.close(String(clientId)) + this.session.close(connectionKey, cause) if (grant) { this.sourceCredit.retainOrCloseOnDetach(grant) } @@ -239,12 +204,8 @@ export class SshPtyConsumerSessionAdapter { return this.sourceCredit.retentionSnapshot() } - deliveryMode(clientId: number): 'source-owner' | 'legacy-owner' | 'subscriber' { - const grant = this.session.activeGrant(String(clientId)) - if (grant?.role !== 'session-owner') { - return 'subscriber' - } - return grant.capabilities?.outputFlowControl ? 'source-owner' : 'legacy-owner' + deliveryMode(clientId: number): SshPtyDeliveryMode { + return sshPtyDeliveryMode(this.session.activeGrant(String(clientId))) } private async openClient( diff --git a/src/relay/ssh-pty-data-publication-admission.ts b/src/relay/ssh-pty-data-publication-admission.ts new file mode 100644 index 00000000000..95c86ee539d --- /dev/null +++ b/src/relay/ssh-pty-data-publication-admission.ts @@ -0,0 +1,38 @@ +import type { PtyConsumerSessionGrant } from '../shared/pty-consumer-session' +import type { SshPtySourceCreditAdapter } from './ssh-pty-source-credit-adapter' + +export type SshPtyDeliveryMode = 'unadmitted' | 'source-owner' | 'legacy-owner' | 'subscriber' + +export function sshPtyDeliveryMode( + grant: Readonly | null +): SshPtyDeliveryMode { + if (!grant) { + return 'unadmitted' + } + if (grant.role !== 'session-owner') { + return 'subscriber' + } + return grant.capabilities?.outputFlowControl ? 'source-owner' : 'legacy-owner' +} + +export function admitsSshPtyDataPublication( + grant: Readonly | null, + params: Readonly>, + sourceCredit: Pick +): boolean { + if (!grant) { + return false + } + if (grant.role !== 'session-owner' || !grant.capabilities?.outputFlowControl) { + return true + } + const id = typeof params.id === 'string' ? params.id : '' + const token = typeof params.deliveryToken === 'string' ? params.deliveryToken : '' + const identity = token ? sourceCredit.ownsDelivery(token, grant, id) : null + return ( + identity !== null && + params.clientGeneration === identity.clientGeneration && + params.ownerGeneration === identity.ownerGeneration && + params.ptyIncarnation === identity.ptyIncarnation + ) +} diff --git a/src/relay/ssh-pty-open-client-request.ts b/src/relay/ssh-pty-open-client-request.ts new file mode 100644 index 00000000000..48d6d255117 --- /dev/null +++ b/src/relay/ssh-pty-open-client-request.ts @@ -0,0 +1,56 @@ +import type { PtyConsumerSessionHello } from '../shared/pty-consumer-session' +import type { RelayClientSessionIdentity, RequestContext } from './dispatcher' + +export type OpenClientParams = PtyConsumerSessionHello & { + protocolVersion: number +} + +// Why every field is coerced rather than trusted: these arrive off the wire from a peer that may be +// a different build, so a missing or wrong-typed member has to degrade into a value the session can +// reject by contract instead of throwing somewhere further in. +export function parseOpenClientParams(params: Record): OpenClientParams { + const resume = + typeof params.resume === 'object' && params.resume !== null + ? (params.resume as Record) + : undefined + const capabilities = + typeof params.capabilities === 'object' && params.capabilities !== null + ? (params.capabilities as Record) + : undefined + const outputFlowControl = + typeof capabilities?.outputFlowControl === 'object' && capabilities.outputFlowControl !== null + ? (capabilities.outputFlowControl as Record) + : undefined + return { + protocolVersion: Number(params.protocolVersion), + clientInstanceId: String(params.clientInstanceId ?? ''), + requestedRole: String(params.requestedRole ?? '') as PtyConsumerSessionHello['requestedRole'], + ...(resume + ? { + resume: { + ownerGeneration: Number(resume.ownerGeneration), + ownerLease: String(resume.ownerLease ?? '') + } + } + : {}), + ...(outputFlowControl + ? { + capabilities: { + outputFlowControl: { + versions: Array.isArray(outputFlowControl.versions) + ? outputFlowControl.versions.map(Number) + : [], + requestedWindowSu: Number(outputFlowControl.requestedWindowSu) + } + } + } + : {}) + } +} + +export function requireIdentity(context: RequestContext): RelayClientSessionIdentity { + if (!context.sessionIdentity) { + throw new Error('SSH PTY consumer transport identity is unavailable') + } + return context.sessionIdentity +} diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 66dddbabee2..ce59662d7f6 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -807,6 +807,7 @@ margin: 1em 0; border-collapse: collapse; font-size: 0.95em; + table-layout: auto; } .rich-markdown-editor th, @@ -815,6 +816,7 @@ border: 1px solid var(--border); text-align: left; vertical-align: top; + overflow-wrap: break-word; } .rich-markdown-editor th { @@ -830,6 +832,109 @@ background: color-mix(in srgb, var(--foreground) 1.5%, transparent); } +.rich-markdown-table-controls { + position: absolute; + inset: 0; + /* Why: the slash (30), emoji (31) and doc-link (30) menus share this stacking + context, so hover affordances must stay under them — above editor content + chrome such as the code-block language selector (1). */ + z-index: 10; + pointer-events: none; +} + +.rich-markdown-table-control { + position: absolute; + z-index: 1; + border-radius: 0; + color: var(--muted-foreground); + pointer-events: auto; + backdrop-filter: blur(8px); +} + +.rich-markdown-table-control::before { + position: absolute; + inset: -8px; + content: ''; +} + +.rich-markdown-table-axis-control { + border: 0; + background: transparent; + box-shadow: none; + opacity: 1; +} + +.rich-markdown-table-axis-control::after { + position: absolute; + content: ''; + background: color-mix(in srgb, var(--foreground) 18%, transparent); + transition: background-color 120ms ease; +} + +.rich-markdown-table-axis-control[data-axis='column'] { + height: 14px; +} + +.rich-markdown-table-axis-control[data-axis='column']::after { + right: 4px; + left: 4px; + height: 2px; +} + +.rich-markdown-table-axis-control[data-axis='row'] { + width: 14px; +} + +.rich-markdown-table-axis-control[data-axis='row']::after { + top: 4px; + bottom: 4px; + width: 2px; +} + +.rich-markdown-table-axis-control svg { + opacity: 0; + transition: opacity 120ms ease; +} + +.rich-markdown-table-axis-control:hover, +.rich-markdown-table-axis-control:focus-visible, +.rich-markdown-table-axis-control[data-state='open'] { + border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); + background: color-mix(in srgb, var(--background) 88%, transparent); + color: var(--foreground); + opacity: 1; +} + +.rich-markdown-table-axis-control:hover::after, +.rich-markdown-table-axis-control:focus-visible::after, +.rich-markdown-table-axis-control[data-state='open']::after { + background: transparent; +} + +.rich-markdown-table-axis-control:hover svg, +.rich-markdown-table-axis-control:focus-visible svg, +.rich-markdown-table-axis-control[data-state='open'] svg { + opacity: 1; +} + +.rich-markdown-table-add-control { + border-color: color-mix(in srgb, var(--border) 76%, transparent); + background: transparent; + color: var(--muted-foreground); + opacity: 0.76; +} + +.rich-markdown-table-add-control:hover, +.rich-markdown-table-add-control:focus-visible { + background: color-mix(in srgb, var(--foreground) 5%, transparent); + color: var(--foreground); + opacity: 1; +} + +.rich-markdown-editor .rich-markdown-table-control-active { + background: color-mix(in srgb, var(--primary) 12%, transparent) !important; +} + .rich-markdown-editor code { padding: 0.2em 0.4em; border-radius: 5px; diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index f8f11c9567b..3eb17140995 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -12,6 +12,7 @@ import { type RichMarkdownEditorCodec } from '@/components/editor/rich-markdown-source-transport' import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar' +import { RichMarkdownTableControls } from '@/components/editor/RichMarkdownTableControls' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -54,6 +55,7 @@ export function LinearIssueMarkdownDescriptionEditor({ const { i18n } = useTranslation() const language = i18n.resolvedLanguage ?? i18n.language const lastEditorMarkdownRef = useRef(value) + const editorScrollRef = useRef(null) const editorRef = useRef(null) const richMarkdownSpellcheckEnabled = useAppStore( (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true @@ -152,8 +154,13 @@ export function LinearIssueMarkdownDescriptionEditor({ )} > -
+
+
diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index ccb1f03c0e5..690f2d42655 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -11,6 +11,7 @@ import { ChevronRight, Clipboard, FolderKanban, + FolderOpen, GitBranch, Link, LoaderCircle, @@ -31,15 +32,29 @@ import { type LinearLocalComment } from '@/components/LinearItemDrawer' import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createBrowserUuid } from '@/lib/browser-uuid' import { buildLinearIssueContextSnapshot } from '@/lib/linear-issue-context-snapshot' +import { + findLinearIssueWorkspaceAttachment, + getLinearIssueWorkspaceAttachmentLabel +} from '@/lib/linear-issue-workspace-attachment' +import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' +import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { buildContainedLinkedContextBlock } from '@/lib/linked-work-item-context' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' +import { useAllWorktrees } from '@/store/selectors' import { buildLinearIssueBranchName, formatLinearIssueRelativeTime @@ -512,6 +527,12 @@ export default function LinearIssueWorkspace({ }: LinearIssueWorkspaceProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) const providerSettings = sourceContext ?? settings + const allWorktrees = useAllWorktrees() + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const attachmentWorkspaces = useMemo( + () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)], + [allWorktrees, folderWorkspaces] + ) const [fullIssue, setFullIssue] = useState(null) const [issueLoading, setIssueLoading] = useState(false) const [comments, setComments] = useState([]) @@ -647,6 +668,14 @@ export default function LinearIssueWorkspace({ const displayed = fullIssue ?? issue + const attachedWorkspace = useMemo( + () => (displayed ? findLinearIssueWorkspaceAttachment(attachmentWorkspaces, displayed) : null), + [attachmentWorkspaces, displayed] + ) + const attachedWorkspaceLabel = attachedWorkspace + ? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace) + : null + const handleUseIssue = useCallback((): void => { if (!displayed) { return @@ -654,6 +683,13 @@ export default function LinearIssueWorkspace({ onUse(displayed) }, [displayed, onUse]) + const handleOpenOrUseIssue = useCallback((): void => { + if (!displayed) { + return + } + openLinearIssueWorkspaceOrStart(displayed, () => onUse(displayed)) + }, [displayed, onUse]) + const handleCommentAdded = useCallback((comment: LinearLocalComment) => { const newComment: LinearComment = { id: comment.id || createBrowserUuid(), @@ -779,24 +815,68 @@ export default function LinearIssueWorkspace({ {translate('auto.components.LinearIssueWorkspace.30c1242f3a', 'Copy identifier')} - - - - - - {translate('auto.components.LinearIssueWorkspace.e1e0a9bca9', 'Start workspace')} - - + {attachedWorkspace ? ( + + + + + + + + + + + {translate( + 'auto.components.LinearIssueWorkspace.startNewWorkspace', + 'Start new workspace' + )} + + + + ) : ( + + + + + + {translate('auto.components.LinearIssueWorkspace.e1e0a9bca9', 'Start workspace')} + + + )} {variant === 'sheet' ? ( @@ -949,6 +1029,33 @@ export default function LinearIssueWorkspace({ onProjectChanged={handleProjectChanged} sourceContext={sourceContext} /> +
+
+ + {translate('auto.components.LinearIssueWorkspace.workspaceSection', 'Workspace')} + +
+
+ {attachedWorkspaceLabel ? ( + + ) : ( +
+ {translate('auto.components.LinearIssueWorkspace.noWorkspaceYet', 'None yet')} +
+ )} +
+
diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 719aecbf4b1..82053896d36 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -1,12 +1,14 @@ /* eslint-disable max-lines -- Why: the Linear drawer co-locates read-only preview, edit controls, and comment input so the full issue surface stays in one file. */ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: Linear drawer state hydrates full issue details and comments from provider IPC for the selected issue. */ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ArrowRight, ChevronDown, ExternalLink, + FolderOpen, Gauge, LoaderCircle, + Plus, Send, Tag, UserRound, @@ -15,6 +17,13 @@ import { import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' @@ -27,7 +36,14 @@ import { getCommentBodySubmitState, hasBoundedCommentBodyText } from '@/lib/comment-body-submit-state' +import { + findLinearIssueWorkspaceAttachment, + getLinearIssueWorkspaceAttachmentLabel +} from '@/lib/linear-issue-workspace-attachment' +import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' +import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { useAppStore } from '@/store' +import { useAllWorktrees } from '@/store/selectors' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { createBrowserUuid } from '@/lib/browser-uuid' import { @@ -1234,6 +1250,12 @@ export default function LinearItemDrawer({ const optimisticCommentsRef = useRef([]) const settings = useAppStore((s) => s.settings) const providerSettings = sourceContext ?? settings + const allWorktrees = useAllWorktrees() + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const attachmentWorkspaces = useMemo( + () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)], + [allWorktrees, folderWorkspaces] + ) const handleEditStateChange = useCallback((patch: Partial) => { hasEditedRef.current = true @@ -1357,6 +1379,20 @@ export default function LinearItemDrawer({ }, []) const displayed = fullIssue ?? issue + const attachedWorkspace = useMemo( + () => (displayed ? findLinearIssueWorkspaceAttachment(attachmentWorkspaces, displayed) : null), + [attachmentWorkspaces, displayed] + ) + const attachedWorkspaceLabel = attachedWorkspace + ? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace) + : null + + const handleOpenOrUseIssue = useCallback((): void => { + if (!displayed) { + return + } + openLinearIssueWorkspaceOrStart(displayed, () => onUse(displayed)) + }, [displayed, onUse]) return ( !open && onClose()}> @@ -1526,28 +1562,75 @@ export default function LinearItemDrawer({
- {/* Comment footer + Start workspace */} + {/* Comment footer + Start/Open workspace */} -
- +
+ {attachedWorkspaceLabel ? ( +
+ + {attachedWorkspaceLabel} +
+ ) : null} + {attachedWorkspace ? ( + + + + + + + + + onUse(displayed)}> + + {translate( + 'auto.components.LinearItemDrawer.startNewWorkspace', + 'Start new workspace' + )} + + + + ) : ( + + )}
)} diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index f04a8f80de4..aa46a10f7bf 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react' +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { lazyWithRetry } from '@/lib/lazy-with-retry' import { @@ -20,6 +20,7 @@ import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import type { + GitHubWorkItem, TuiAgent, WorkspaceCreateTelemetrySource, WorkspaceStatus @@ -41,9 +42,11 @@ type ComposerModalData = { initialEphemeralVmRecipeId?: string initialProjectGroupId?: string linkedWorkItem?: LinkedWorkItemSummary | null + initialGitHubWorkItem?: GitHubWorkItem | null taskSourceContext?: TaskSourceContext | null initialBaseBranch?: string initialWorkspaceStatus?: WorkspaceStatus + enableIssueAutomation?: boolean /** Telemetry surface that opened the composer. Set by each * `openModal('new-workspace-composer', ...)` site so * `workspace_created.source` carries the right value. Falls back to @@ -58,42 +61,29 @@ export default function NewWorkspaceComposerModal(): React.JSX.Element | null { const modalData = useAppStore((s) => s.modalData as ComposerModalData | undefined) const closeModal = useAppStore((s) => s.closeModal) - // Why: Dialog open-state transitions must be driven by the store, not a - // mirror useState, so palette/open-modal calls feel instantaneous and the - // modal doesn't linger with stale data after close. - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open) { - closeModal() - } - }, - [closeModal] - ) - if (!visible) { return null } - return ( - - ) + return } function ComposerModalBody({ modalData, - onClose, - onOpenChange + onClose }: { modalData: ComposerModalData onClose: () => void - onOpenChange: (open: boolean) => void }): React.JSX.Element { + const submitCancelledRef = useRef(false) + const handleDismiss = useCallback(() => { + submitCancelledRef.current = true + onClose() + }, [onClose]) + const isSubmissionCancelled = useCallback(() => submitCancelledRef.current, []) + return ( - + !open && handleDismiss()}> { @@ -106,7 +96,13 @@ function ComposerModalBody({ getWorkspaceComposerInitialFocusTarget(content)?.focus({ preventScroll: true }) }} > - + ) @@ -115,10 +111,14 @@ function ComposerModalBody({ function QuickTabBody({ modalData, onClose, + onDismiss, + isSubmissionCancelled, active }: { modalData: ComposerModalData onClose: () => void + onDismiss: () => void + isSubmissionCancelled: () => boolean active: boolean }): React.JSX.Element { const settings = useAppStore((s) => s.settings) @@ -136,6 +136,7 @@ function QuickTabBody({ // intentionally ignored even if older callers still send it. initialPrompt: '', initialLinkedWorkItem: modalData.linkedWorkItem ?? null, + initialGitHubWorkItem: modalData.initialGitHubWorkItem ?? null, initialTaskSourceContext: modalData.taskSourceContext ?? null, initialRepoId: modalData.initialRepoId, initialEphemeralVmRecipeId: modalData.initialEphemeralVmRecipeId, @@ -144,8 +145,9 @@ function QuickTabBody({ ...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}), persistDraft: false, onCreated: onClose, + isSubmissionCancelled, ...(modalData.telemetrySource ? { telemetrySource: modalData.telemetrySource } : {}), - enableIssueAutomation: false, + enableIssueAutomation: modalData.enableIssueAutomation === true, createGateMode: 'quick' }) // Why: the composer's built-in `onOpenAgentSettings` handler navigates to @@ -267,7 +269,7 @@ function QuickTabBody({ return } event.preventDefault() - onClose() + onDismiss() return } @@ -287,7 +289,7 @@ function QuickTabBody({ } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen, onClose]) + }, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen, onDismiss]) return ( <> diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 0f2fa5c12fb..2c3917ab1f4 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: repo selector, task-source controls, and task list stay co-located so their wiring reads in one place. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { @@ -32,6 +32,7 @@ import { Users, X, FolderKanban, + FolderOpen, Tag, UserRound } from 'lucide-react' @@ -138,9 +139,48 @@ import { findGithubWorkItemWorkspaceAttachment, getGithubWorkItemWorkspaceAttachmentLabel } from '@/lib/github-work-item-workspace-attachment' -import { createGitHubWorkItemWorkspaceInBackground } from '@/lib/github-work-item-background-create' +import { + buildLinearIssueWorkspaceAttachmentIndex, + findLinearIssueWorkspaceAttachmentInIndex, + getLinearIssueWorkspaceAttachmentLabel +} from '@/lib/linear-issue-workspace-attachment' +import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' +import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { useTaskPageGitHubWorkItemMutation } from '@/hooks/useTaskPageGitHubWorkItemMutation' +import { useMountedRef } from '@/hooks/useMountedRef' +import { + advanceTaskPageQuietRevalidateScope, + clearTaskPageGitHubAuthorityAbsentFromLoadedItems, + clearTaskPageGitHubAuthorityThroughGeneration, + getTaskPageQuietRevalidateBackoffAttempt, + getTaskPageGitHubRevalidatableAuthorityItemKeys, + isTaskPageQuietRevalidateRunCurrent, + isTaskPageQuietRevalidateScopeCurrent, + LAG_BACKOFF_MS, + LAG_WALL_BUDGET_MS, + materializeTaskPageItemList, + MAX_LAG_TRAILS, + overlayPendingOnTaskPagePages, + patchTaskPageGitHubWorkItemPages, + processTaskPageQuietRevalidateSettle, + reapplyPendingTaskPageGitHubMutationsToCache, + reconcileTaskPagePagesAfterQuietRefresh, + rebuildSoftHiddenKeysFromPendingAndSticky +} from '@/components/task-page-github-work-item-mutations' +import { + getOrCreateQuietRevalidateState, + getTaskPageGitHubConfirmedAuthorityItemKeys, + setTaskPageGitHubMutationQueryKey, + taskPageGitHubItemKey +} from '@/components/task-page-github-work-item-mutation-registry' +import { + beginTaskPageQuietRevalidateRun, + finishTaskPageQuietRevalidateRun +} from '@/components/task-page-github-work-item-quiet-state' +import type { TaskPageGitHubMutationIntent } from '@/components/task-page-github-work-item-mutation-patches' +import type { TaskPageGitHubPatchWorkItem } from '@/components/task-page-github-work-item-mutation-types' import GitHubItemDialog, { type ItemDialogTab } from '@/components/GitHubItemDialog' import PullRequestPage from '@/components/PullRequestPage' import GitLabItemDialog from '@/components/GitLabItemDialog' @@ -228,6 +268,13 @@ import { resolveLinearIssueEmptyKind, shouldOfferLinearIssueFetchMore } from '@/components/task-page-linear-issue-empty-state' +import { + collectLinkedLinearIssueRefsFromWorktrees, + filterLinearIssuesBySearchQuery, + filterLinearIssuesForInOrcaWorkspace, + linkedLinearIssueRefsSignature, + readLinkedLinearIssuesWithLimit +} from '@/components/task-page-linear-in-orca-issues' import { emptyLinearIssueAttributeFilter, linearIssueAttributeFilterSignature, @@ -404,10 +451,11 @@ const GITHUB_TASK_GRID_CLASS = 'min-w-[790px] grid-cols-[72px_minmax(320px,1fr)_84px_100px_92px_122px]' const GITHUB_PR_TASK_GRID_CLASS = 'min-w-[1020px] grid-cols-[72px_minmax(360px,2fr)_132px_128px_132px_92px_158px]' -const GITHUB_TASK_ROW_SURFACE_CLASS = - '[background:color-mix(in_srgb,var(--muted)_50%,var(--background))]' -const GITHUB_TASK_ROW_HOVER_SURFACE_CLASS = - 'group-hover/github-task-row:[background:color-mix(in_srgb,var(--muted)_70%,var(--background))]' +// Why: sticky cells need the row's opaque, animated surface to prevent bleed and hover flashes. +const GITHUB_TASK_ROW_SURFACE_CLASS = 'bg-background transition-colors' +const GITHUB_TASK_ROW_HOVER_SURFACE_CLASS = 'group-hover/github-task-row:bg-accent' +const GITHUB_TASK_HEADER_SURFACE_CLASS = + '[background:color-mix(in_srgb,var(--muted)_25%,var(--background))]' function getGitHubWorkItemWorkspaceSeed(item: GitHubWorkItem): string { return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) @@ -526,14 +574,15 @@ function getTaskPageRepoCacheInput(repo: Repo): { } } -// Why: sticky header bg must be opaque or scrolled rows bleed through; the ::before gap-cover keeps horizontally-scrolled columns off the px-3 padding strip. +// Why: opaque sticky headers and a padding-gap cover prevent vertical and horizontal bleed. const GITHUB_TASK_STICKY_ID_HEADER_CLASS = cn( - 'sticky left-3 z-30 before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit', - GITHUB_TASK_ROW_SURFACE_CLASS + // Why: full-height flex keeps the sticky fill from shrinking around its label. + 'sticky left-3 z-30 flex items-center before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit', + GITHUB_TASK_HEADER_SURFACE_CLASS ) const GITHUB_TASK_STICKY_TITLE_HEADER_CLASS = cn( - 'sticky left-[92px] z-30 border-r border-border/50 before:absolute before:-left-2 before:top-0 before:bottom-0 before:w-2 before:bg-inherit', - GITHUB_TASK_ROW_SURFACE_CLASS + 'sticky left-[92px] z-30 flex items-center border-r border-border/40 before:absolute before:-left-2 before:top-0 before:bottom-0 before:w-2 before:bg-inherit', + GITHUB_TASK_HEADER_SURFACE_CLASS ) const GITHUB_TASK_STICKY_ID_CELL_CLASS = cn( 'sticky left-3 z-20 flex items-center before:absolute before:-left-3 before:top-0 before:bottom-0 before:w-3 before:bg-inherit', @@ -541,7 +590,7 @@ const GITHUB_TASK_STICKY_ID_CELL_CLASS = cn( GITHUB_TASK_ROW_HOVER_SURFACE_CLASS ) const GITHUB_TASK_STICKY_TITLE_CELL_CLASS = cn( - 'sticky left-[92px] z-20 min-w-0 border-r border-border/50 pr-2 before:absolute before:-left-2 before:top-0 before:bottom-0 before:w-2 before:bg-inherit', + 'sticky left-[92px] z-20 flex min-w-0 flex-col justify-center border-r border-border/40 pr-2 before:absolute before:-left-2 before:top-0 before:bottom-0 before:w-2 before:bg-inherit', GITHUB_TASK_ROW_SURFACE_CLASS, GITHUB_TASK_ROW_HOVER_SURFACE_CLASS ) @@ -936,6 +985,7 @@ function getLinearIssueGridTemplate(visibleProperties: ReadonlySet 0 ? customFields : undefined } +type TaskPageGitHubWorkItemMutationRunner = { + run: (input: { + item: GitHubWorkItem + intent: TaskPageGitHubMutationIntent + sourceContext?: TaskSourceContext | null + mutate: () => Promise<{ ok?: boolean; error?: string | { message?: string } } | void> + successToast?: string + errorToast: string + }) => Promise<'confirmed' | 'rolled_back' | 'stale'> + isIntentPending: (input: { + item: GitHubWorkItem + intent: TaskPageGitHubMutationIntent + sourceContext?: TaskSourceContext | null + }) => boolean +} + function GHStatusCell({ item, repo, - sourceContext + sourceContext, + workItemMutation }: { item: GitHubWorkItem repo: Repo | null sourceContext?: TaskSourceContext | null + workItemMutation: TaskPageGitHubWorkItemMutationRunner }): React.JSX.Element { - const patchWorkItem = useAppStore((s) => s.patchWorkItem) const [statusStateDraft, setStatusStateDraft] = useState(() => createTaskPageGitHubStatusStateDraft(item) ) const [open, setOpen] = useState(false) + const [statusUpdating, setStatusUpdating] = useState(false) const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false) const [duplicateSearch, setDuplicateSearch] = useState('') const [duplicateError, setDuplicateError] = useState(null) @@ -1110,7 +1178,6 @@ function GHStatusCell({ : repoOwnerSettings, [repoOwnerSettings, sourceContext] ) - const reqRef = useRef(0) const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url]) const filteredDuplicateCandidates = useMemo( () => @@ -1140,6 +1207,11 @@ function GHStatusCell({ setStatusStateDraft(resolvedStatusStateDraft) } const localState = resolvedStatusStateDraft.localState + const stateMutationPending = workItemMutation.isIntentPending({ + item, + intent: { type: 'setState', state: localState === 'open' ? 'closed' : 'open' }, + sourceContext + }) const updateLocalState = useCallback( (nextState: GitHubWorkItem['state']) => { setStatusStateDraft((current) => @@ -1150,46 +1222,59 @@ function GHStatusCell({ ) const handleStateChange = useCallback( - (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { - if (newState === localState || item.type !== 'issue') { + async (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { + if ( + statusUpdating || + stateMutationPending || + newState === localState || + item.type !== 'issue' + ) { return } const parsedOwnerRepo = parsedIssueLink?.slug if (!repo && !parsedOwnerRepo) { return } - reqRef.current += 1 - const reqId = reqRef.current const updates: GitHubIssueUpdate = newState === 'closed' && closeAction ? buildTaskPageGitHubCloseUpdate(closeAction) : { state: newState } updateLocalState(newState) - patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) - const target = getActiveRuntimeTarget(sourceSettings) - // Why: issue rows can be sourced by owner/repo URL, not local repo context; slug-aware writes preserve close reasons and duplicates. - const updatePromise = parsedOwnerRepo - ? target.kind === 'environment' - ? callRuntimeRpc<{ ok?: boolean; error?: { message?: string } | string }>( - target, - 'github.project.updateIssueBySlug', - { - owner: parsedOwnerRepo.owner, - repo: parsedOwnerRepo.repo, - host: githubProjectHost(parsedOwnerRepo.host), - number: item.number, - updates - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.updateIssueBySlug({ - owner: parsedOwnerRepo.owner, - repo: parsedOwnerRepo.repo, - host: githubProjectHost(parsedOwnerRepo.host), - number: item.number, - updates - }) - : (() => { + // Why: coordinator owns durable patch + soft-hide + quiet revalidate; keep + // the status draft so one-frame flash is still covered until proven safe. + setStatusUpdating(true) + try { + await workItemMutation.run({ + item, + intent: { type: 'setState', state: newState, closeAction }, + sourceContext, + errorToast: translate('auto.components.TaskPage.1c893195ac', 'Failed to update state'), + mutate: async () => { + const target = getActiveRuntimeTarget(sourceSettings) + // Why: issue rows can be sourced by owner/repo URL instead of the local + // repo context; slug-aware writes preserve close reasons and duplicates. + if (parsedOwnerRepo) { + return target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: { message?: string } | string }>( + target, + 'github.project.updateIssueBySlug', + { + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, + host: githubProjectHost(parsedOwnerRepo.host), + number: item.number, + updates + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.updateIssueBySlug({ + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, + host: githubProjectHost(parsedOwnerRepo.host), + number: item.number, + updates + }) + } if (!repo) { throw new Error('No GitHub repository context available for this issue.') } @@ -1209,59 +1294,25 @@ function GHStatusCell({ number: item.number, updates }) - })() - updatePromise - .then((result) => { - if (reqId !== reqRef.current) { - return } - const typed = result as { ok?: boolean; error?: string | { message?: string } } - if (typed && typed.ok === false) { - updateLocalState(newState === 'closed' ? 'open' : 'closed') - patchWorkItem( - item.id, - { state: newState === 'closed' ? 'open' : 'closed' }, - item.repoId, - { sourceContext } - ) - toast.error( - typeof typed.error === 'string' - ? typed.error - : (typed.error?.message ?? - translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) - ) - return - } - if (repo) { - useAppStore.getState().evictGitHubRepoCaches(repo.id, repo.path) - } - useAppStore.getState().recordFeatureInteraction('github-tasks') - }) - .catch(() => { - if (reqId !== reqRef.current) { - return - } - updateLocalState(newState === 'closed' ? 'open' : 'closed') - patchWorkItem( - item.id, - { state: newState === 'closed' ? 'open' : 'closed' }, - item.repoId, - { - sourceContext - } - ) - toast.error(translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) }) + } finally { + setStatusUpdating(false) + } + // Why: draft realigns from item.state via resolveTaskPageGitHubStatusStateDraft + // when patchWorkItem (begin/rollback) updates the cache-backed row. }, [ item, localState, parsedIssueLink, - patchWorkItem, repo, sourceContext, sourceSettings, - updateLocalState + stateMutationPending, + statusUpdating, + updateLocalState, + workItemMutation ] ) @@ -1310,6 +1361,7 @@ function GHStatusCell({