diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c06100173c6..bbcedfea963 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -4263,28 +4263,25 @@ "terminal input availability", "worktree discovery", "Source Control status refresh", - "direct SSH detected-worktree scheduling", - "direct SSH reconnect telemetry", - "direct SSH host catalog authority", "direct SSH generation-scope rollover" ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "ssh"], - "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, paired-runtime publication to two independent clients with reconnect and stale-cache checks, the direct SSH five-slot fair scheduler, timeout barrier, aggregate privacy schema, coordinator-to-renderer telemetry wiring, host-catalog provenance rejection, and process generation-scope rollover across sibling targets. A headed macOS Electron host and separate paired Electron client both rendered a worktree created through the real Git binary and watcher entry path without reconnect. A macOS Electron client also completed a direct SSH disconnect/reconnect against an ephemeral Linux Docker target with exact host/authority hydration and remote proof-file verification. Linux/Windows desktop clients, multi-target live fanout, and WSL runs remain gaps.", + "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, paired-runtime publication to two independent clients with reconnect and stale-cache checks, and process generation-scope rollover across sibling targets. A headed macOS Electron host and separate paired Electron client both rendered a worktree created through the real Git binary and watcher entry path without reconnect. Linux/Windows desktop clients, multi-target live fanout, and WSL runs remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7086", "https://github.com/stablyai/orca/issues/16477" ], - "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Nested SSH watchers and colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. Direct SSH reconnect discovery must stay host- and authority-qualified, reject contradictory main-catalog provenance without returning rows, admit at most five locally unsettled provider calls, retain a retrying timeout barrier, and emit one identifier-free aggregate product event per target operation. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.", - "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a nested SSH connection id or a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, reject catalog rows whose explicit and legacy host provenance contradict, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, count locally unsettled attempts and round-robin admissions, keep lineage blocked through the first timeout retry, distinguish timeout/rejection/cancel/stale results, and reject telemetry properties carrying target, repo, host, path, label, user, request, lease, terminal, or raw-error data.", + "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Nested SSH watchers and colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.", + "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a nested SSH connection id or a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, and fill a partial pushed authority before clearing that target's PTY bindings once.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/watched-worktree-catalog-notification.test.ts src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts src/main/window/attach-main-window-services.test.ts", "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-external-worktree-discovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot" ], "testFiles": [ "src/main/ipc/worktree-base-directory-event-filter.test.ts", @@ -4301,11 +4298,7 @@ "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts", "src/main/ipc/repos-remote.test.ts", "src/main/ssh/ssh-connection-generation.test.ts", - "src/main/ssh/ssh-provider-authority.test.ts", - "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", - "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", - "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts" + "src/main/ssh/ssh-provider-authority.test.ts" ], "assertionRefs": [ { @@ -4367,7 +4360,7 @@ { "file": "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts", "assertions": [ - "direct SSH coordinator telemetry is wired through the fail-soft product adapter" + "a filled partial SSH authority clears the target's PTY bindings once and a conflicting reply is rejected" ] }, { @@ -4423,36 +4416,6 @@ "generation-scope rollover invalidates every target authority before abort callbacks run", "every registered old-scope provider request aborts exactly once" ] - }, - { - "file": "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", - "assertions": [ - "coordinator-owned locally unsettled provider work never exceeds five and target lanes round-robin", - "the first timeout remains retrying and reports queue wait separately from provider execution", - "cancel debt admits at most two replacements and terminally distinguishes budget exhaustion" - ] - }, - { - "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", - "assertions": [ - "lineage and token creation remain blocked until a timed-out repo retry settles", - "exact overlapping preparation emits one aggregate with a join count", - "telemetry callback failure cannot affect reconnect completion" - ] - }, - { - "file": "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", - "assertions": [ - "timeout, rejection, cancellation, and stale outcomes have independent fields", - "target, repo, host, path, label, user, request, lease, terminal, and raw-error fields are rejected" - ] - }, - { - "file": "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", - "assertions": [ - "one coordinator aggregate maps to one typed product event with queue and provider percentiles", - "adapter failure is swallowed before it can reach recovery" - ] } ], "evidenceRuns": [ @@ -4475,13 +4438,13 @@ "summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation." }, { - "date": "2026-07-27", + "date": "2026-10-11", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 3.45, - "summary": "Five focused direct SSH scheduler, coordinator, telemetry, and hook-wiring files passed with 130 tests." + "durationSeconds": 1.21, + "summary": "The direct SSH state-bridge wiring file passed with 6 tests after the reconnect coordinator, scheduler and telemetry were deleted." }, { "date": "2026-08-26", @@ -4512,11 +4475,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. The direct SSH tests encode failures for unbounded admission, early lineage release, merged timeout/rejection/cancel/stale results, identifier-bearing telemetry, duplicate joined events, and telemetry exceptions, but no intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion." + "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. No intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion." }, "performanceBudget": { "required": true, - "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. Direct SSH coordinator-owned detected-worktree work is capped at five locally unsettled calls with a two-call late-work allowance; terminal finalization precedes provider discovery, and queue wait and provider execution are reported separately. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; no equivalent live direct SSH fanout benchmark is claimed." + "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows." }, "promotionCriteria": [ "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", @@ -12958,7 +12921,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-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/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-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-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.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-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "pnpm run build:web-from-renderer", @@ -13001,8 +12964,6 @@ "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/components/terminal-pane/terminal-pane-tab-detach.test.ts", "src/main/ipc/repos-remote.test.ts", @@ -13270,20 +13231,6 @@ "a provider result becomes stale when same-ID repo ownership turns malformed or contradictory during the await" ] }, - { - "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", - "assertions": [ - "terminal invalidation and retry run synchronously before provider preparation", - "hydrated terminal finalization and same-authority correction are current-authority fenced", - "rapid authority rotation keeps immediate terminal checks while damping full preparation" - ] - }, - { - "file": "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts", - "assertions": [ - "exact-host catalog and lineage hydration preserves sibling SSH, local, runtime, ambiguous, and contradictory rows" - ] - }, { "file": "src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts", "assertions": [ @@ -13317,7 +13264,7 @@ "date": "2026-08-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.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/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-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-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", "durationSeconds": 10.7, "summary": "Fourteen direct-SSH files and 155 tests passed, including 32-arrival placement and readiness bursts with one listener/timer maximum, immediate supersession and stop cleanup, latest-only hydration, stale-push fencing, pending-capture and in-flight-write upload exclusion, and existing retry, authority, hydration, split-pane, and detach contracts." @@ -13353,7 +13300,7 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.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/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-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-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", "durationSeconds": 15.8, "summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning." diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 6d3e961d2fe..5d0cdc3e667 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -2841,7 +2841,6 @@ "src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts": 559, "src/main/ipc/worktrees/listing/detected-worktree-scan-superseded.test.ts": 68, "src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 197, - "src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts": 80, "src/main/ipc/zcode-plan-credentials.test.ts": 58, "src/main/issue-command-ignore.test.ts": 620, "src/main/jcode/daemon-prewarm.test.ts": 54, @@ -9019,14 +9018,8 @@ "src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 1678, "src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 1661, "src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 110, - "src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts": 1364, - "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 79, - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 130, - "src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 49, - "src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 41, "src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 1605, "src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 183, - "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 72, "src/renderer/src/hooks/editor-external-watch-fallback-explorer.test.ts": 355, "src/renderer/src/hooks/editor-external-watch-selected-host.test.ts": 324, "src/renderer/src/hooks/editor-runtime-file-watch.test.ts": 55, @@ -9298,7 +9291,6 @@ "src/renderer/src/lib/desktop-window-chrome.test.ts": 56, "src/renderer/src/lib/diff-comment-compat.test.ts": 50, "src/renderer/src/lib/diff-comments-format.test.ts": 47, - "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 56, "src/renderer/src/lib/direct-ssh-target-scope.test.ts": 70, "src/renderer/src/lib/doc-preview-grants.test.ts": 370, "src/renderer/src/lib/document-theme.test.ts": 55, @@ -10835,7 +10827,6 @@ "src/shared/cross-platform-path.test.ts": 55, "src/shared/currency-format.test.ts": 47, "src/shared/daemon-adoption-telemetry.test.ts": 349, - "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 279, "src/shared/doc-preview-file-access.test.ts": 93, "src/shared/doc-preview-scheme.test.ts": 44, "src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 75, diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index 6e4de0adbdd..74b9626574d 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -30,13 +30,8 @@ vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' -import { - getSshProviderAuthority, - resetSshProviderAuthorities, - rotateSshProviderAuthority -} from '../ssh/ssh-provider-authority' +import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { getGitRepoRoot, isGitRepo } from '../git/repo' -import { toSshExecutionHostId } from '../../shared/execution-host' import { createRepoHandlerHarness, resetProjectGroupMocks } from './repos-remote-test-harness' const { handleMock, mockStore } = reposMocks @@ -65,128 +60,6 @@ describe('projectGroups IPC validation', () => { expect(mockStore.createProjectGroup).not.toHaveBeenCalled() }) - it('returns an immutable repo catalog for exactly one execution host', async () => { - const localRepo = { - id: 'duplicate', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { - id: 'duplicate', - path: '/remote/repo', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1' - } - const runtimeRepo = { - id: 'duplicate', - path: '/runtime/repo', - displayName: 'runtime', - badgeColor: '#000', - addedAt: 0, - executionHostId: 'runtime:environment-a' - } - mockStore.getRepos.mockReturnValue([localRepo, sshRepo, runtimeRepo]) - - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: getSshProviderAuthority('conn-1') - }) - ).resolves.toMatchObject({ - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: 'ssh:conn-1', - targetId: 'conn-1' - }, - repos: [sshRepo] - }) - - const local = await handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: 'local' - }) - expect(local).toMatchObject({ authoritative: true, repos: [localRepo] }) - expect((local as { repos: object[] }).repos[0]).not.toBe(localRepo) - }) - - it('rejects repo catalogs whose execution host contradicts their SSH connection', async () => { - const baseRepo = { - id: 'repo-1', - path: '/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0 - } - mockStore.getRepos.mockReturnValue([ - { - ...baseRepo, - connectionId: 'conn-1', - executionHostId: 'local' - } - ]) - - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: 'local' - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: getSshProviderAuthority('conn-1') - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - - mockStore.getRepos.mockReturnValue([ - { - ...baseRepo, - connectionId: 'conn-1', - executionHostId: toSshExecutionHostId('conn-2') - } - ]) - - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: getSshProviderAuthority('conn-1') - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - }) - - it('rejects runtime, partial, mismatched, and stale catalog authority', async () => { - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: 'runtime:environment-a' - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1') - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: { - ...getSshProviderAuthority('other-target'), - targetId: 'other-target' - } - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - - const expectedAuthority = getSshProviderAuthority('conn-1') - const pending = handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority - }) - rotateSshProviderAuthority('conn-1') - await expect(pending).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) - }) - it('rejects malformed local project group update arguments before persistence', () => { expect(() => handlers.get('projectGroups:update')!(null, { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 9e7a4175c22..3c12365aa0d 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -22,7 +22,6 @@ export function registerRepoHandlers( ): void { // Remove previously registered handlers so we can re-register on macOS app re-activation (new window). ipcMain.removeHandler('repos:list') - ipcMain.removeHandler('repos:listForExecutionHost') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') diff --git a/src/main/ipc/repos/host-repo-catalog-snapshot.ts b/src/main/ipc/repos/host-repo-catalog-snapshot.ts deleted file mode 100644 index 3e16cf6245b..00000000000 --- a/src/main/ipc/repos/host-repo-catalog-snapshot.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { Store } from '../../persistence' -import type { Repo } from '../../../shared/repo-types' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../../shared/host-repo-catalog-contract' -import { - getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId -} from '../../../shared/execution-host' -import { isAdmissibleDirectSshAuthority } from '../../../shared/ssh-retained-payload-admission' -import { isCurrentSshProviderAuthority } from '../../ssh/ssh-provider-authority' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { visibleRepos } from '../../ssh/orcad-retained-source' - -function hasValidCatalogSshAuthority( - args: ListReposForExecutionHostArgs -): args is Extract { - if (!('expectedAuthority' in args)) { - return false - } - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} - -function repoHostContradictsConnection(repo: Repo): boolean { - if (!repo.executionHostId || !repo.connectionId) { - return false - } - const explicitHost = parseExecutionHostId(repo.executionHostId) - return explicitHost?.kind !== 'ssh' || explicitHost.targetId !== repo.connectionId -} - -function getConsistentRepoCatalogForHost( - repos: readonly Repo[], - host: NonNullable> -): Repo[] | null { - const hasContradiction = repos.some( - (repo) => - repoHostContradictsConnection(repo) && - (getRepoExecutionHostId(repo) === host.id || - (host.kind === 'ssh' && repo.connectionId === host.targetId)) - ) - return hasContradiction ? null : repos.filter((repo) => getRepoExecutionHostId(repo) === host.id) -} - -export async function listReposForExecutionHost( - store: Store, - args: ListReposForExecutionHostArgs -): Promise { - const parsedHost = parseExecutionHostId(args?.executionHostId) - const rejected = ( - reason: Extract['reason'] - ): HostRepoCatalogSnapshot => ({ - authoritative: false, - executionHostId: args.executionHostId, - reason - }) - if (!parsedHost || parsedHost.kind === 'runtime') { - return rejected('rejected') - } - if (parsedHost.kind === 'local') { - if ('expectedAuthority' in args) { - return rejected('rejected') - } - const repos = getConsistentRepoCatalogForHost(visibleRepos(store), parsedHost) - if (!repos) { - return rejected('rejected') - } - return { - authoritative: true, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - repos: structuredClone(repos) - } - } - if ( - !hasValidCatalogSshAuthority(args) || - args.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - const authority = { ...args.expectedAuthority } - if (!isCurrentSshProviderAuthority(authority)) { - return rejected('stale') - } - const provider = getSshGitProvider(parsedHost.targetId) - if (!provider) { - return rejected('unavailable') - } - const matchingRepos = getConsistentRepoCatalogForHost(visibleRepos(store), parsedHost) - if (!matchingRepos) { - return rejected('rejected') - } - const repos = structuredClone(matchingRepos) - await Promise.resolve() - if ( - getSshGitProvider(parsedHost.targetId) !== provider || - !isCurrentSshProviderAuthority(authority) - ) { - return rejected('stale') - } - return { - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: parsedHost.id, - ...authority - }, - repos - } -} diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index 608d9105f19..ddb5d058056 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -2,17 +2,12 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import type { Store } from '../../persistence' import type { Project, ProjectUpdateArgs } from '../../../shared/project-types' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../../shared/host-repo-catalog-contract' import { normalizeExecutionHostId } from '../../../shared/execution-host' import { enrichRepoGitUsernames } from '../../repo-git-username-enrichment' import { enrichMissingRepoGitRemoteIdentities } from '../../repo-git-remote-identity-enrichment' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' import { notifyReposChanged } from './repos-changed-notification' import { ProjectUpdateIpcArgs, parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas' -import { listReposForExecutionHost } from './host-repo-catalog-snapshot' import { visibleRepos } from '../../ssh/orcad-retained-source' export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: Store): void { @@ -27,12 +22,6 @@ export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: St return visibleRepos(store) }) - ipcMain.handle( - 'repos:listForExecutionHost', - (_event, args: ListReposForExecutionHostArgs): Promise => - listReposForExecutionHost(store, args) - ) - ipcMain.handle('projects:list', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged }) return store.getProjects() diff --git a/src/main/ipc/worktree-create-lineage.test.ts b/src/main/ipc/worktree-create-lineage.test.ts index fa7e101ac3f..46c6e4d2a63 100644 --- a/src/main/ipc/worktree-create-lineage.test.ts +++ b/src/main/ipc/worktree-create-lineage.test.ts @@ -150,7 +150,6 @@ describe('recordWorkspaceLineageForCreatedWorktree', () => { expect(store.setWorktreeLineage).not.toHaveBeenCalled() expect(result.lineage).toBeNull() - // A persisted cross-host row makes filterLineageForHost return null for the entire host. expect(store.setWorkspaceLineage).not.toHaveBeenCalled() expect(result.workspaceLineage).toBeNull() } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 2a78b770e36..db38fec58da 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -285,9 +285,7 @@ export function recordWorkspaceLineageForCreatedWorktree( } } - // Why: persisting a workspace row for an out-of-boundary worktree parent poisons the whole host — - // `filterLineageForHost` returns null for any owned row whose child and parent hosts differ, so - // every nesting on that host stops hydrating, and the row survives restarts. + // Why: a workspace row for an out-of-boundary worktree parent is a cross-host link that survives restarts. if (parentOutsideLineageBoundary) { return NO_CREATED_WORKTREE_LINEAGE } diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index e0e7b6cc0e4..0109057430a 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -36,7 +36,6 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:forceDeletePreservedBranch', 'worktrees:updateMeta', 'worktrees:listLineage', - 'worktrees:listLineageForHost', 'worktrees:updateLineage', 'worktrees:persistSortOrder', 'worktrees:getBranchRenameFailureOutput', diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index 68de6fb5dd3..08163de9d87 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -1,13 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { Worktree } from '../../shared/worktree/types' -import { toSshExecutionHostId } from '../../shared/execution-host' -import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees/metadata/host-lineage-listing' -import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks' -import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' -import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -92,156 +88,8 @@ vi.mock('../runtime/worktree-teardown', async () => vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) describe('registerWorktreeHandlers', () => { - let runtimeStub: WorktreeRuntimeStub - beforeEach(() => { - runtimeStub = setupWorktreeHandlers() - }) - - it('answers SSH lineage as unavailable without reading this machine', async () => { - const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: toSshExecutionHostId('target-a'), - expectedAuthority: getSshProviderAuthority('target-a') - }) - - expect(result).toEqual({ - authoritative: false, - executionHostId: 'ssh:target-a', - reason: 'unavailable' - }) - expect(runtimeStub.hydrateInferredWorktreeLineage).not.toHaveBeenCalled() - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) - - it('snapshots lineage catalogs once and memoizes repeated owner resolution', async () => { - const worktreeIds = Array.from( - { length: 101 }, - (_, index) => `repo-1::/workspace/repo-${index}` - ) - const lineage = Object.fromEntries( - worktreeIds.slice(1).map((worktreeId, index) => [ - worktreeId, - { - worktreeId, - worktreeInstanceId: `child-${index}`, - parentWorktreeId: worktreeIds[index], - parentWorktreeInstanceId: `parent-${index}`, - origin: 'cli', - capture: { source: 'cwd-context', confidence: 'inferred' }, - createdAt: index - } - ]) - ) - store.getAllWorktreeLineage.mockReturnValue(lineage) - store.getRepos.mockClear() - store.getFolderWorkspaces.mockClear() - store.getProjectGroups.mockClear() - store.getWorktreeMeta.mockClear() - - const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: 'local' - }) - - expect(result).toMatchObject({ authoritative: true }) - expect( - Object.keys((result as { worktreeLineageById: Record }).worktreeLineageById) - ).toHaveLength(100) - expect(store.getRepos).toHaveBeenCalledOnce() - expect(store.getFolderWorkspaces).toHaveBeenCalledOnce() - expect(store.getProjectGroups).toHaveBeenCalledOnce() - expect(store.getWorktreeMeta).toHaveBeenCalledTimes(101) - }) - - it('preserves ambiguous legacy lineage instead of guessing among duplicate repo owners', async () => { - const child = 'duplicate::/child' - const parent = 'duplicate::/parent' - store.getRepos.mockReturnValue([ - { - id: 'duplicate', - path: '/local', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - }, - { - id: 'duplicate', - path: '/remote', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'target-a' - } - ]) - store.getAllWorktreeLineage.mockReturnValue({ - [child]: { - worktreeId: child, - worktreeInstanceId: 'child', - parentWorktreeId: parent, - parentWorktreeInstanceId: 'parent', - origin: 'cli', - capture: { source: 'cwd-context', confidence: 'inferred' }, - createdAt: 1 - } - }) - - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { executionHostId: 'local' }) - ).resolves.toEqual({ - authoritative: false, - executionHostId: 'local', - reason: 'ambiguous-owner' - }) - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) - - it('rejects runtime lineage reads and stale SSH authority', async () => { - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: 'runtime:environment-a' - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - expect(runtimeStub.hydrateInferredWorktreeLineage).not.toHaveBeenCalled() - - const authority = getSshProviderAuthority('target-a') - rotateSshProviderAuthority('target-a') - - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: toSshExecutionHostId('target-a'), - expectedAuthority: authority - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) - - it('bounds noncooperative lineage hydration and permits a later read', async () => { - vi.useFakeTimers() - try { - runtimeStub.hydrateInferredWorktreeLineage.mockReturnValue(new Promise(() => {})) - const pending = handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: 'local' - }) - await vi.advanceTimersByTimeAsync(LINEAGE_HYDRATION_TIMEOUT_MS - 1) - let settled = false - void Promise.resolve(pending).finally(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await expect(pending).resolves.toMatchObject({ - authoritative: false, - reason: 'unavailable' - }) - expect(vi.getTimerCount()).toBe(0) - - runtimeStub.hydrateInferredWorktreeLineage.mockResolvedValue(undefined) - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { executionHostId: 'local' }) - ).resolves.toMatchObject({ authoritative: true }) - } finally { - vi.useRealTimers() - } + setupWorktreeHandlers() }) it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 0e06b3ee0ec..c581c9f2e15 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -47,7 +47,6 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:forceDeletePreservedBranch', 'worktrees:updateMeta', 'worktrees:listLineage', - 'worktrees:listLineageForHost', 'worktrees:updateLineage', 'worktrees:persistSortOrder', 'worktrees:getBranchRenameFailureOutput', diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 491a2342ee2..a5b78aa53de 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -14,7 +14,6 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' import type { DirectSshDetectedWorktreeRequest } from '../../../../shared/detected-worktree-provider-contract' import { isAdmissibleDirectSshAuthority } from '../../../../shared/ssh-retained-payload-admission' -import type { ListDesktopLineageForHostArgs } from '../../../../shared/host-lineage-contract' import { buildDetectedGitWorktrees, createSshWorktreeMetaIndex, @@ -206,12 +205,3 @@ export function hasValidDirectSshAuthority( ): args is DirectSshDetectedWorktreeRequest { return isAdmissibleDirectSshAuthority(args.expectedAuthority) } - -export function hasValidLineageSshAuthority( - args: ListDesktopLineageForHostArgs -): args is Extract { - if (!('expectedAuthority' in args)) { - return false - } - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} diff --git a/src/main/ipc/worktrees/metadata/host-lineage-listing.ts b/src/main/ipc/worktrees/metadata/host-lineage-listing.ts deleted file mode 100644 index 48a25ec9d4c..00000000000 --- a/src/main/ipc/worktrees/metadata/host-lineage-listing.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import type { Store } from '../../../persistence/loading-store/store' -import type { - ListDesktopLineageForHostArgs, - HostLineageSnapshot -} from '../../../../shared/host-lineage-contract' -import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import { isCurrentSshProviderAuthority } from '../../../ssh/ssh-provider-authority' -import { hasValidLineageSshAuthority } from '../listing/detected-provider-listing' -import { filterLineageForHost } from './workspace-lineage-filtering' - -export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 - -export async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { - let timeout: ReturnType | undefined - const hydration = Promise.resolve() - .then(() => runtime.hydrateInferredWorktreeLineage()) - .then( - () => true, - () => false - ) - const deadline = new Promise((resolve) => { - timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) - }) - try { - return await Promise.race([hydration, deadline]) - } finally { - if (timeout) { - clearTimeout(timeout) - } - } -} - -export async function listDesktopLineageForHost( - store: Store, - runtime: OrcaRuntimeService, - args: ListDesktopLineageForHostArgs -): Promise { - const parsedHost = parseExecutionHostId(args?.executionHostId) - const rejected = ( - reason: Extract['reason'] - ): HostLineageSnapshot => ({ - authoritative: false, - executionHostId: args.executionHostId, - reason - }) - if (!parsedHost || parsedHost.kind === 'runtime') { - return rejected('rejected') - } - if (parsedHost.kind === 'ssh') { - if ( - !hasValidLineageSshAuthority(args) || - args.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - // An `ssh:` host is not served here, so its lineage is never answered from this machine. - return rejected( - isCurrentSshProviderAuthority({ ...args.expectedAuthority }) ? 'unavailable' : 'stale' - ) - } - if ('expectedAuthority' in args) { - return rejected('rejected') - } - if (!(await hydrateLineageWithinDeadline(runtime))) { - return rejected('unavailable') - } - const lineage = filterLineageForHost(store, parsedHost.id) - if (!lineage) { - return rejected('ambiguous-owner') - } - return { - authoritative: true, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - ...lineage - } -} diff --git a/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts b/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts deleted file mode 100644 index a8a58bf81d1..00000000000 --- a/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { parseExecutionHostId } from '../../../../shared/execution-host' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { Store } from '../../../persistence/loading-store/store' -import type { Repo } from '../../../../shared/repo-types' -import { parseWorktreeId } from '../../worktree-logic' -import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import { resolveRepoOwnershipEvidence } from '../listing/worktree-host-ownership' - -export type LineageOwner = - | { status: 'owned'; hostId: ExecutionHostId } - | { status: 'ambiguous' | 'contradictory' | 'runtime' } - -export type LineageFolder = FolderWorkspace -export type LineageGroup = ProjectGroup - -export type LineageResolutionContext = { - store: Store - repos: Repo[] - groups: LineageGroup[] - reposById: Map - foldersById: Map - groupsById: Map - groupSubtreeIdsByRoot: Map> - worktreeOwners: Map - folderOwners: Map - workspaceOwners: Map -} - -export function indexLineageEntriesById( - entries: readonly T[] -): Map { - const index = new Map() - for (const entry of entries) { - const matching = index.get(entry.id) ?? [] - matching.push(entry) - index.set(entry.id, matching) - } - return index -} - -export function createLineageResolutionContext(store: Store): LineageResolutionContext { - const repos = store.getRepos() - const folders = store.getFolderWorkspaces() - const groups = store.getProjectGroups() - return { - store, - repos, - groups, - reposById: indexLineageEntriesById(repos), - foldersById: indexLineageEntriesById(folders), - groupsById: indexLineageEntriesById(groups), - groupSubtreeIdsByRoot: new Map(), - worktreeOwners: new Map(), - folderOwners: new Map(), - workspaceOwners: new Map() - } -} - -export function resolveRepoLineageOwner(repo: Repo): LineageOwner { - const owner = resolveRepoOwnershipEvidence(repo) - if (owner.status === 'malformed') { - return { status: 'ambiguous' } - } - if (owner.status === 'contradictory') { - return { status: 'contradictory' } - } - return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner -} - -export function resolveWorktreeLineageOwner( - context: LineageResolutionContext, - worktreeId: string -): LineageOwner { - const cached = context.worktreeOwners.get(worktreeId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.worktreeOwners.set(worktreeId, owner) - return owner - } - let repoId: string - try { - repoId = parseWorktreeId(worktreeId).repoId - } catch { - return remember({ status: 'ambiguous' }) - } - const repos = context.reposById.get(repoId) ?? [] - const meta = context.store.getWorktreeMeta(worktreeId) - const runtimeOwnerEnvironmentId = ( - meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined - )?.runtimeOwnerEnvironmentId?.trim() - if (runtimeOwnerEnvironmentId) { - return remember({ status: 'runtime' }) - } - if (meta?.hostId) { - const explicitHost = parseExecutionHostId(meta.hostId) - if (!explicitHost) { - return remember({ status: 'ambiguous' }) - } - if (explicitHost.kind === 'runtime') { - return remember({ status: 'runtime' }) - } - const matchingRepos = repos.filter((repo) => { - const owner = resolveRepoLineageOwner(repo) - return owner.status === 'owned' && owner.hostId === explicitHost.id - }) - if (matchingRepos.length === 1) { - return remember({ status: 'owned', hostId: explicitHost.id }) - } - return remember( - matchingRepos.length > 1 - ? { status: 'ambiguous' } - : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } - ) - } - if (repos.length !== 1) { - return remember({ status: 'ambiguous' }) - } - return remember(resolveRepoLineageOwner(repos[0])) -} diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts index 0fe85e09884..1cb36b600b1 100644 --- a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -4,17 +4,12 @@ import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display- import { parseExecutionHostId } from '../../../../shared/execution-host' import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' -import type { - ListDesktopLineageForHostArgs, - HostLineageSnapshot -} from '../../../../shared/host-lineage-contract' import { notifyWorktreesChanged } from '../../worktree-remote' import { parseWorktreeId } from '../../worktree-logic' import { planWorktreeSortOrderUpdates } from '../../../../shared/worktree/sort-order-update' import { readBranchRenameFailureOutputForDisplay } from '../../../agent-hooks/branch-rename-failure-output' import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' import { isRemovedFolderWorkspaceInstance } from '../../../folder-workspace-meta-presence' -import { listDesktopLineageForHost } from './host-lineage-listing' import type { WorktreeIpcContext } from '../worktree-ipc-context' export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void { @@ -72,12 +67,6 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v } }) - ipcMain.handle( - 'worktrees:listLineageForHost', - (_event, args: ListDesktopLineageForHostArgs): Promise => - listDesktopLineageForHost(store, runtime, args) - ) - ipcMain.handle( 'worktrees:updateLineage', async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts deleted file mode 100644 index e3ccee07086..00000000000 --- a/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/repo-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import { createLineageResolutionContext } from './lineage-owner-resolution' -import { getFolderLineageCandidateRepos } from './workspace-lineage-filtering' - -vi.mock('../../worktree-logic', () => ({ parseWorktreeId: vi.fn() })) - -function repo(id: string, fields: Partial = {}): Repo { - return { id, path: `/root/${id}`, displayName: id, badgeColor: '#000', addedAt: 0, ...fields } -} - -function group(id = 'group', fields: Partial = {}): ProjectGroup { - return { - id, - name: id, - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 0, - updatedAt: 0, - ...fields - } -} - -function folder(fields: Partial = {}): FolderWorkspace { - return { - id: 'folder', - projectGroupId: 'group', - name: 'folder', - folderPath: '/root', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - createdAt: 0, - updatedAt: 0, - ...fields - } -} - -function context(repos: Repo[], groups = [group()]) { - const store = { - getRepos: () => repos, - getFolderWorkspaces: () => [], - getProjectGroups: () => groups - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Context construction only reads these three store catalogs. - return createLineageResolutionContext(store as never) -} - -describe('folder lineage candidate lookup', () => { - it('reads grouped target membership once across a large path-candidate list', () => { - let targetReads = 0 - const grouped = Array.from({ length: 300 }, (_, index): Repo => ({ - ...repo(`grouped-${index}`, { projectGroupId: 'group', path: `/elsewhere/${index}` }), - get connectionId() { - targetReads += 1 - return null - } - })) - const candidates = Array.from({ length: 1_000 }, (_, index) => repo(`path-${index}`)) - const result = getFolderLineageCandidateRepos(context([...grouped, ...candidates]), folder()) - - expect(result).toEqual([...grouped, ...candidates]) - expect(targetReads).toBe(300) - }) - - it('preserves descendant-group priority, path boundaries and local/SSH membership', () => { - const entries = [ - repo('path-a', { connectionId: 'a' }), - repo('group-b', { projectGroupId: 'nested', path: '/elsewhere', connectionId: 'b' }), - repo('local-null', { connectionId: null }), - repo('group-local', { projectGroupId: 'group' }), - repo('path-b', { connectionId: 'b' }), - repo('local-absent'), - repo('sibling', { path: '/root-other/repo', connectionId: 'b' }), - repo('root', { path: '/root', connectionId: 'b' }) - ] - const snapshot = structuredClone(entries) - const result = getFolderLineageCandidateRepos( - context(entries, [group(), group('nested', { parentGroupId: 'group' })]), - folder() - ) - - expect(result.map((entry) => entry.id)).toEqual([ - 'group-b', - 'group-local', - 'local-null', - 'path-b', - 'local-absent', - 'root' - ]) - expect(result[0]).toBe(entries[1]) - expect(entries).toEqual(snapshot) - }) - - it.each([ - { folderTarget: 'a', groupTarget: 'b', expected: ['grouped', 'a'] }, - { folderTarget: null, groupTarget: 'b', expected: ['grouped', 'b'] }, - { folderTarget: undefined, groupTarget: undefined, expected: ['grouped', 'local'] } - ])( - 'keeps explicit target precedence: $folderTarget / $groupTarget', - ({ folderTarget, groupTarget, expected }) => { - const entries = [ - repo('a', { connectionId: 'a' }), - repo('b', { connectionId: 'b' }), - repo('local'), - repo('grouped', { projectGroupId: 'group', path: '/outside' }) - ] - expect( - getFolderLineageCandidateRepos( - context(entries, [group('group', { connectionId: groupTarget })]), - folder({ connectionId: folderTarget }) - ).map((entry) => entry.id) - ).toEqual(expected) - } - ) - - it('keeps every contained target when the folder has no grouped repositories', () => { - const entries = [ - repo('local'), - repo('remote', { connectionId: 'a' }), - repo('outside', { path: '/elsewhere' }) - ] - expect(getFolderLineageCandidateRepos(context(entries), folder())).toEqual(entries.slice(0, 2)) - }) - - it('does not read grouped targets when there are no path candidates', () => { - let targetReads = 0 - const grouped: Repo = { - ...repo('grouped', { projectGroupId: 'group' }), - get connectionId() { - targetReads += 1 - return 'a' - } - } - expect(getFolderLineageCandidateRepos(context([grouped]), folder())).toEqual([grouped]) - expect(targetReads).toBe(0) - }) - - it('rebuilds target membership after a later change in the same context', () => { - const grouped = repo('grouped', { projectGroupId: 'group', connectionId: 'a' }) - const a = repo('a', { connectionId: 'a' }) - const b = repo('b', { connectionId: 'b' }) - const current = context([grouped, a, b]) - expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, a]) - grouped.connectionId = 'b' - expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, b]) - }) -}) diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts deleted file mode 100644 index c478b59b81a..00000000000 --- a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { Repo } from '../../../../shared/repo-types' -import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' -import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' -import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import type { Store } from '../../../persistence/loading-store/store' -import type { WorktreeLineage, WorkspaceLineage } from '../../../../shared/worktree/lineage-types' -import { - createLineageResolutionContext, - resolveRepoLineageOwner, - resolveWorktreeLineageOwner -} from './lineage-owner-resolution' -import type { - LineageFolder, - LineageOwner, - LineageResolutionContext -} from './lineage-owner-resolution' - -export function getFolderLineageCandidateRepos( - context: LineageResolutionContext, - folder: LineageFolder -): Repo[] { - let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) - if (!groupIds) { - groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) - context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) - } - const grouped = context.repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const pathRepos = context.repos.filter( - (repo) => - !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && - isPathInsideOrEqual(folder.folderPath, repo.path) - ) - const group = context.groupsById.get(folder.projectGroupId)?.[0] - const connectionId = folder.connectionId ?? group?.connectionId ?? null - if (connectionId) { - return [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] - } - if (grouped.length === 0) { - return pathRepos - } - if (pathRepos.length === 0) { - return grouped - } - const groupedConnectionIds = new Set(grouped.map((repo) => repo.connectionId ?? null)) - return [ - ...grouped, - ...pathRepos.filter((repo) => groupedConnectionIds.has(repo.connectionId ?? null)) - ] -} - -export function resolveFolderLineageOwner( - context: LineageResolutionContext, - folderWorkspaceId: string -): LineageOwner { - const cached = context.folderOwners.get(folderWorkspaceId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.folderOwners.set(folderWorkspaceId, owner) - return owner - } - const folders = context.foldersById.get(folderWorkspaceId) ?? [] - if (folders.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const folder = folders[0] - const groups = context.groupsById.get(folder.projectGroupId) ?? [] - if (groups.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const group = groups[0] - const hosts = new Set() - if (folder.connectionId) { - hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) - } - if (group.connectionId) { - hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) - } - if (group.executionHostId) { - const parsed = parseExecutionHostId(group.executionHostId) - if (!parsed) { - return remember({ status: 'ambiguous' }) - } - hosts.add(parsed.id) - } - for (const repo of getFolderLineageCandidateRepos(context, folder)) { - const owner = resolveRepoLineageOwner(repo) - if (owner.status !== 'owned') { - return remember(owner) - } - hosts.add(owner.hostId) - } - if (hosts.size > 1) { - return remember({ status: 'contradictory' }) - } - const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID - return remember( - parseExecutionHostId(hostId)?.kind === 'runtime' - ? { status: 'runtime' } - : { status: 'owned', hostId } - ) -} - -export function resolveWorkspaceLineageOwner( - context: LineageResolutionContext, - workspaceKey: string -): LineageOwner { - const cached = context.workspaceOwners.get(workspaceKey) - if (cached) { - return cached - } - const workspace = parseWorkspaceKey(workspaceKey) - const owner = !workspace - ? { status: 'ambiguous' as const } - : workspace.type === 'worktree' - ? resolveWorktreeLineageOwner(context, workspace.worktreeId) - : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) - context.workspaceOwners.set(workspaceKey, owner) - return owner -} - -export function filterLineageForHost( - store: Store, - executionHostId: ExecutionHostId -): { - worktreeLineageById: Record - workspaceLineageByChildKey: Record -} | null { - const context = createLineageResolutionContext(store) - const worktreeLineageById: Record = {} - const workspaceLineageByChildKey: Record = {} - for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - const child = resolveWorktreeLineageOwner(context, worktreeId) - const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - worktreeLineageById[worktreeId] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { - const child = resolveWorkspaceLineageOwner(context, childKey) - const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - workspaceLineageByChildKey[childKey] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - return { worktreeLineageById, workspaceLineageByChildKey } -} diff --git a/src/main/ssh/orcad-retained-source-lists.test.ts b/src/main/ssh/orcad-retained-source-lists.test.ts index 3c9f2903e99..6ada0e73d81 100644 --- a/src/main/ssh/orcad-retained-source-lists.test.ts +++ b/src/main/ssh/orcad-retained-source-lists.test.ts @@ -6,7 +6,6 @@ import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import type { SshTarget } from '../../shared/ssh-types' -import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' import type { Store } from '../persistence' import { orcadMigrationCutoverFixture } from './orcad-migration-cutover-fixture' import { writeOrcadMigrationSourceCutover } from './orcad-migration-cutover-journal' @@ -15,13 +14,9 @@ const userData = vi.hoisted(() => ({ dir: '' })) vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getPath: () => userData.dir }) })) -vi.mock('./ssh-provider-authority', () => ({ isCurrentSshProviderAuthority: () => true })) -const provider = {} -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: () => provider })) const { isFrozenOrcadSourceSessionPartition, visibleProjectGroups } = await import('./orcad-retained-source') -const { listReposForExecutionHost } = await import('../ipc/repos/host-repo-catalog-snapshot') const FENCED: SshTarget = { id: 'ssh-box', @@ -125,16 +120,4 @@ describe('lists while a converted host keeps its source rows', () => { rmSync(userDataPath, { recursive: true, force: true }) } }) - - it('leaves the source repos out of the host catalog the SSH bridge hydrates from', async () => { - const authority: unknown = { targetId: FENCED.id, providerEpoch: 'e1', connectionGeneration: 1 } - if (!isAdmissibleDirectSshAuthority(authority)) { - throw new Error('fixture authority rejected') - } - const snapshot = await listReposForExecutionHost(catalog([FENCED]), { - executionHostId: `ssh:${FENCED.id}`, - expectedAuthority: authority - }) - expect(snapshot).toMatchObject({ authoritative: true, repos: [] }) - }) }) diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts index b7eae7dbb03..2357912c640 100644 --- a/src/preload/api/repos-bridge.ts +++ b/src/preload/api/repos-bridge.ts @@ -1,8 +1,4 @@ import { ipcRenderer } from 'electron' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../shared/host-repo-catalog-contract' import type { BaseRefDefaultResult, BaseRefSearchResult } from '../../shared/repo-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { PreloadApi } from '../api-types' @@ -10,9 +6,6 @@ import type { PreloadApi } from '../api-types' export const reposApi = { list: () => ipcRenderer.invoke('repos:list'), - listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => - ipcRenderer.invoke('repos:listForExecutionHost', args), - add: (args) => ipcRenderer.invoke('repos:add', args), addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index dd76d3f1b23..2efa64fcbf6 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -1,9 +1,5 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { GhAccountBinding } from '../../shared/github/account-binding' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../shared/host-repo-catalog-contract' import type { NestedRepoScanResult, ProjectGroup, @@ -27,7 +23,6 @@ import type { BaseRefDefaultResult, BaseRefSearchResult, Repo } from '../../shar export type RepositoryApi = { list: () => Promise - listForExecutionHost?: (args: ListReposForExecutionHostArgs) => Promise // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. add: (args: { path: string diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index a0b17122722..aa4bb092182 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -15,10 +15,6 @@ import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest } from '../../shared/folder-workspace-path-status' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../../shared/host-lineage-contract' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { WorktreeBaseStatusEvent, @@ -131,7 +127,6 @@ export type WorktreeApi = { lineage: Record workspaceLineage?: Record }> - listLineageForHost?: (args: ListDesktopLineageForHostArgs) => Promise updateLineage: (args: { worktreeId: string parentWorktreeId?: string diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index b141c1cd17a..1dc4dfdd67e 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -1,8 +1,4 @@ import { ipcRenderer } from 'electron' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../../shared/host-lineage-contract' import type { WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent @@ -60,9 +56,6 @@ export const worktreesApi = { listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), - listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => - ipcRenderer.invoke('worktrees:listLineageForHost', args), - updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), diff --git a/src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts b/src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts deleted file mode 100644 index 0568f79bf9b..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../shared/repo-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { createTestStore } from '../store/slices/store-test-helpers' -import { createDirectSshHostHydration } from './direct-ssh-host-hydration' - -const authority: DirectSshAuthority = { - targetId: 'target-a', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture uses one opaque epoch identity throughout. - providerEpoch: 'catalog-epoch' as SshProviderEpoch, - connectionGeneration: 1 -} - -const remoteRepo: Repo = { - id: 'shared', - path: '/srv/repo', - displayName: 'Remote repo', - badgeColor: '', - addedAt: 1, - connectionId: authority.targetId, - executionHostId: 'ssh:target-a', - hookSettings: { mode: 'auto', scripts: { setup: 'echo ready', archive: '' } }, - symlinkPaths: ['.env'], - repoIcon: { type: 'lucide', name: 'Box' } -} - -function fixture() { - const localRepo: Repo = { - ...remoteRepo, - path: '/local/repo', - connectionId: null, - executionHostId: 'local' - } - const folderRepo: Repo = { ...remoteRepo, id: 'folder', kind: 'folder', path: '/srv/folder' } - const initialRepos = [remoteRepo, localRepo, folderRepo] - const store = createTestStore() - store.setState({ - repos: initialRepos, - manualRepoOrder: [ - { hostId: 'ssh:target-a', repoId: 'shared' }, - { hostId: 'local', repoId: 'shared' }, - { hostId: 'ssh:target-a', repoId: 'folder' } - ] - }) - let fetched: readonly Repo[] = [remoteRepo, folderRepo] - const hydration = createDirectSshHostHydration({ - store, - listRepos: async () => ({ - authoritative: true, - authority: { kind: 'direct-ssh', executionHostId: 'ssh:target-a', ...authority }, - repos: structuredClone(fetched) - }), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - const listener = vi.fn() - store.subscribe(listener) - return { - store, - hydration, - listener, - initialRepos, - localRepo, - folderRepo, - setFetched: (repos: readonly Repo[]) => { - fetched = repos - } - } -} - -describe('direct SSH host catalog publication', () => { - it('does not publish cloned equal repos across twenty refreshes', async () => { - const { store, hydration, listener, initialRepos } = fixture() - const initialState = store.getState() - for (let index = 0; index < 20; index++) { - await hydration.capturePreparationInput(authority, 'wake-refresh') - } - expect(store.getState().repos).toBe(initialRepos) - expect(store.getState()).toBe(initialState) - expect(listener).not.toHaveBeenCalled() - hydration.stop() - }) - - it('publishes a nested change once and retains equal sibling identities', async () => { - const { store, hydration, listener, localRepo, folderRepo, setFetched } = fixture() - const changed = { ...remoteRepo, symlinkPaths: ['.env', '.env.local'] } - setFetched([changed, folderRepo]) - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(store.getState().repos).toEqual([changed, localRepo, folderRepo]) - expect(store.getState().repos[0]).not.toBe(remoteRepo) - expect(store.getState().repos[1]).toBe(localRepo) - expect(store.getState().repos[2]).toBe(folderRepo) - expect(listener).toHaveBeenCalledOnce() - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(listener).toHaveBeenCalledOnce() - hydration.stop() - }) - - it('removes only target rows and retains manual order when adding a repo', async () => { - const { store, hydration, listener, localRepo, folderRepo, setFetched } = fixture() - const added = { ...remoteRepo, id: 'added', path: '/srv/added' } - setFetched([added, folderRepo]) - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(store.getState().repos).toEqual([localRepo, folderRepo, added]) - expect(store.getState().repos[0]).toBe(localRepo) - expect(store.getState().repos[1]).toBe(folderRepo) - expect(listener).toHaveBeenCalledOnce() - hydration.stop() - }) - - it('still advances catalog observation revisions when rows stay equal', async () => { - const { hydration, listener } = fixture() - const first = await hydration.capturePreparationInput(authority, 'reconnect') - const second = await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(first).not.toBeNull() - expect(second?.catalogRevision).toBe(first!.catalogRevision + 1) - const repoFingerprint = JSON.stringify([ - ['ssh:target-a', 'folder'], - ['ssh:target-a', 'shared'] - ]) - expect( - hydration.isPreparationTokenCurrent({ - authority, - catalogRevision: first!.catalogRevision, - repoFingerprint - }) - ).toBe(false) - expect( - hydration.isPreparationTokenCurrent({ - authority, - catalogRevision: second!.catalogRevision, - repoFingerprint - }) - ).toBe(true) - expect(listener).not.toHaveBeenCalled() - hydration.stop() - }) - - // Why these two shapes: the gate must fail closed on a field no previous row carried, so a - // `Repo` field added after this gate landed still publishes without anyone revisiting the - // comparison. `'auto'` is documented as semantically identical to an absent - // `issueSourcePreference`, which makes it the sharpest case for the strict own-key policy. - it.each([ - { - label: 'an optional key no previous row carried', - changed: { ...remoteRepo, issueSourcePreference: 'auto' } satisfies Repo - }, - { - label: 'a nested record no previous row carried', - changed: { ...remoteRepo, upstream: { owner: 'octo', repo: 'remote' } } satisfies Repo - } - ])('publishes when a fetched row gains $label', async ({ changed }) => { - const { store, hydration, listener, folderRepo, setFetched } = fixture() - setFetched([changed, folderRepo]) - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(listener).toHaveBeenCalledOnce() - expect(store.getState().repos).toContainEqual(changed) - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(listener).toHaveBeenCalledOnce() - hydration.stop() - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts b/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts deleted file mode 100644 index 7bb5e6cfd53..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' -import type { AppState } from '../store/types' - -export function directSshHostHydrationScope( - state: AppState, - authority: DirectSshAuthority, - catalogRevision: number -) { - return resolveDirectSshTargetScope({ - targetId: authority.targetId, - catalogRevision, - repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, - folderWorkspaces: state.folderWorkspaces, - projectGroups: state.projectGroups, - restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey - }) -} diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts b/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts deleted file mode 100644 index 7e1e776674a..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { DirectSshTargetScope } from '../lib/direct-ssh-target-scope' -import type { DirectSshPreparationInputTelemetry } from './direct-ssh-reconnect-coordinator' - -export function directSshHostHydrationTelemetry( - scope: DirectSshTargetScope, - catalogOutcome: DirectSshPreparationInputTelemetry['catalogOutcome'], - catalogDurationMs: number -): DirectSshPreparationInputTelemetry { - return { - catalogOutcome, - catalogDurationMs, - gitWorktreeCount: scope.gitWorktreeIds.size, - folderWorkspaceCount: Math.max(0, scope.terminalWorkspaceKeys.size - scope.gitWorktreeIds.size), - ambiguousOwnerCount: scope.ambiguousOwnerCount, - contradictoryOwnerCount: scope.contradictoryOwnerCount - } -} diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts deleted file mode 100644 index cc13aa983aa..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts +++ /dev/null @@ -1,545 +0,0 @@ -import { createStore } from 'zustand/vanilla' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' -import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import type { Repo } from '../../../shared/repo-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import type { AppState } from '../store/types' -import { createDirectSshHostHydration } from './direct-ssh-host-hydration' - -function authority(targetId = 'target-a', epoch = 'epoch-a'): DirectSshAuthority { - return { - targetId, - providerEpoch: epoch as SshProviderEpoch, - connectionGeneration: 1 - } -} - -function repo(id: string, targetId: string | null): Repo { - return { - id, - path: targetId ? `/${targetId}/${id}` : `/local/${id}`, - projectGroupId: null, - connectionId: targetId, - executionHostId: targetId ? (`ssh:${targetId}` as const) : ('local' as const) - } as Repo -} - -function state(overrides: Record = {}): AppState { - return { - repos: [], - worktreesByRepo: {}, - detectedWorktreesByRepo: {}, - folderWorkspaces: [], - projectGroups: [], - restoredRuntimeHostIdByWorkspaceSessionKey: {}, - worktreeLineageById: {}, - workspaceLineageByChildKey: {}, - ...overrides - } as unknown as AppState -} - -function productionLineage(worktreeId: string, parentWorktreeId: string): WorktreeLineage { - return { - worktreeId, - worktreeInstanceId: `${worktreeId}-instance`, - parentWorktreeId, - parentWorktreeInstanceId: `${parentWorktreeId}-instance`, - origin: 'orchestration', - capture: { source: 'orchestration-context', confidence: 'explicit' }, - taskId: 'task-1', - coordinatorHandle: 'coord-1', - createdAt: 1 - } -} - -function productionWorkspaceLineage(childId: string, parentId: string): WorkspaceLineage { - return { - childWorkspaceKey: worktreeWorkspaceKey(childId), - childInstanceId: `${childId}-instance`, - parentWorkspaceKey: worktreeWorkspaceKey(parentId), - parentInstanceId: `${parentId}-instance`, - origin: 'orchestration', - capture: { source: 'orchestration-context', confidence: 'explicit' }, - taskId: 'task-1', - coordinatorHandle: 'coord-1', - createdAt: 1 - } -} - -function hostSnapshot( - owner: DirectSshAuthority, - repos: ReturnType[] -): HostRepoCatalogSnapshot { - return { - authoritative: true as const, - authority: { - kind: 'direct-ssh' as const, - executionHostId: `ssh:${owner.targetId}` as const, - ...owner - }, - repos - } -} - -afterEach(() => { - vi.useRealTimers() -}) - -describe('createDirectSshHostHydration', () => { - it('replaces only the exact SSH host catalog when repo IDs collide', async () => { - const owner = authority() - const store = createStore(() => - state({ - repos: [repo('shared', null), repo('shared', 'target-a'), repo('shared', 'target-b')] - }) - ) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => hostSnapshot(owner, [repo('shared', 'target-a')])), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - const input = await hydration.capturePreparationInput(owner, 'reconnect') - - expect(input?.repoRefs).toEqual([{ repoId: 'shared', executionHostId: 'ssh:target-a' }]) - expect(store.getState().repos).toEqual([ - repo('shared', null), - repo('shared', 'target-b'), - repo('shared', 'target-a') - ]) - }) - - it('keeps the manual cross-host order when the host catalog is republished', async () => { - const owner = authority('box') - const store = createStore(() => - state({ - repos: [repo('bravo', 'box'), repo('alpha', null), repo('delta', 'box')], - manualRepoOrder: [ - { hostId: 'ssh:box', repoId: 'bravo' }, - { hostId: 'local', repoId: 'alpha' }, - { hostId: 'ssh:box', repoId: 'delta' } - ] - }) - ) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => - hostSnapshot(owner, [repo('bravo', 'box'), repo('delta', 'box')]) - ), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - await hydration.capturePreparationInput(owner, 'reconnect') - - expect(store.getState().repos.map((entry) => entry.id)).toEqual(['bravo', 'alpha', 'delta']) - }) - - // A repo added after the last drag has no overlay entry. Ranked rows must keep their order and - // the newcomer sinks to the tail, rather than the overlay being discarded for being incomplete. - it('keeps ranked rows in order and appends unranked ones when the overlay is partial', async () => { - const owner = authority('box') - const store = createStore(() => - state({ - repos: [repo('bravo', 'box'), repo('alpha', null), repo('delta', 'box')], - manualRepoOrder: [ - { hostId: 'ssh:box', repoId: 'delta' }, - { hostId: 'local', repoId: 'alpha' } - ] - }) - ) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => - hostSnapshot(owner, [repo('bravo', 'box'), repo('delta', 'box')]) - ), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - await hydration.capturePreparationInput(owner, 'reconnect') - - expect(store.getState().repos.map((entry) => entry.id)).toEqual(['delta', 'alpha', 'bravo']) - }) - - it('rejects a mismatched host response without publishing', async () => { - const owner = authority() - const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) - const before = store.getState().repos - let publications = 0 - store.subscribe(() => { - publications += 1 - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => - hostSnapshot(authority('target-b', 'epoch-b'), [repo('wrong', 'target-b')]) - ), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - await expect(hydration.capturePreparationInput(owner, 'wake-refresh')).resolves.toBeNull() - expect(store.getState().repos).toBe(before) - expect(publications).toBe(0) - }) - - it('settles a non-cooperative catalog read at five seconds and clears its timer', async () => { - vi.useFakeTimers() - const owner = authority() - const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) - let resolveLate!: (value: HostRepoCatalogSnapshot) => void - const lateSnapshot = new Promise((resolve) => { - resolveLate = resolve - }) - let publications = 0 - store.subscribe(() => { - publications += 1 - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(() => lateSnapshot), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - const pending = hydration.capturePreparationInput(owner, 'wake-refresh') - let settled = false - void pending.then(() => { - settled = true - }) - await vi.advanceTimersByTimeAsync(4_999) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await expect(pending).resolves.toMatchObject({ - catalogRevision: 0, - repoRefs: [{ repoId: 'cached', executionHostId: 'ssh:target-a' }] - }) - expect(vi.getTimerCount()).toBe(0) - resolveLate(hostSnapshot(owner, [repo('late', 'target-a')])) - await Promise.resolve() - await Promise.resolve() - expect(store.getState().repos).toEqual([repo('cached', 'target-a')]) - expect(publications).toBe(0) - }) - - it('discards a catalog reply after authority changes with zero mutation', async () => { - const owner = authority() - const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) - let current = true - let resolve!: (value: ReturnType) => void - const pendingSnapshot = new Promise((settle) => { - resolve = settle - }) - let publications = 0 - store.subscribe(() => { - publications += 1 - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: () => pendingSnapshot, - listLineage: vi.fn(), - isCurrentAuthority: () => current - }) - - const pending = hydration.capturePreparationInput(owner, 'reconnect') - current = false - resolve(hostSnapshot(owner, [repo('new', 'target-a')])) - - await expect(pending).resolves.toBeNull() - expect(store.getState().repos).toEqual([repo('cached', 'target-a')]) - expect(publications).toBe(0) - }) - - it('settles pending host reads and clears their timers on stop', async () => { - vi.useFakeTimers() - const owner = authority() - const store = createStore(() => state()) - const hydration = createDirectSshHostHydration({ - store, - listRepos: () => new Promise(() => {}), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - const pending = hydration.capturePreparationInput(owner, 'wake-refresh') - await Promise.resolve() - expect(vi.getTimerCount()).toBe(1) - hydration.stop() - - await expect(pending).resolves.toBeNull() - expect(vi.getTimerCount()).toBe(0) - }) - - it('replaces only exact-host Git and folder lineage across both key namespaces', async () => { - const owner = authority() - const store = createStore(() => - state({ - repos: [ - repo('a', 'target-a'), - repo('b', 'target-b'), - repo('ambiguous', 'target-a'), - repo('ambiguous', 'target-a'), - repo('contradictory', 'target-a') - ], - worktreesByRepo: { - a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }], - b: [{ id: 'b::/work', repoId: 'b', hostId: 'ssh:target-b' }], - ambiguous: [ - { - id: 'ambiguous::/work', - repoId: 'ambiguous', - hostId: 'ssh:target-a' - } - ], - contradictory: [ - { - id: 'contradictory::/work', - repoId: 'contradictory', - hostId: 'ssh:target-b' - } - ] - }, - projectGroups: [ - { - id: 'group-a', - parentGroupId: null, - connectionId: 'target-a', - executionHostId: 'ssh:target-a' - }, - { - id: 'group-b', - parentGroupId: null, - connectionId: 'target-b', - executionHostId: 'ssh:target-b' - } - ], - folderWorkspaces: [ - { - id: 'folder-a', - projectGroupId: 'group-a', - folderPath: '/target-a/folder', - connectionId: 'target-a' - }, - { - id: 'folder-b', - projectGroupId: 'group-b', - folderPath: '/target-b/folder', - connectionId: 'target-b' - } - ], - worktreeLineageById: { - 'a::/work': { parentWorktreeId: 'stale-a' }, - 'b::/work': { parentWorktreeId: 'keep-b' }, - 'ambiguous::/work': { parentWorktreeId: 'keep-ambiguous' }, - 'contradictory::/work': { parentWorktreeId: 'keep-contradictory' } - }, - workspaceLineageByChildKey: { - [worktreeWorkspaceKey('a::/work')]: { parentWorkspaceKey: 'stale-a-workspace' }, - [worktreeWorkspaceKey('b::/work')]: { parentWorkspaceKey: 'keep-b-workspace' }, - [worktreeWorkspaceKey('ambiguous::/work')]: { - parentWorkspaceKey: 'keep-ambiguous-workspace' - }, - [worktreeWorkspaceKey('contradictory::/work')]: { - parentWorkspaceKey: 'keep-contradictory-workspace' - }, - [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'stale-folder' }, - [folderWorkspaceKey('folder-b')]: { parentWorkspaceKey: 'keep-folder-b' } - } - }) - ) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(), - listLineage: vi.fn( - async () => - ({ - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: 'ssh:target-a', - ...owner - }, - worktreeLineageById: { - 'a::/work': { parentWorktreeId: 'fresh-a' }, - 'b::/work': { parentWorktreeId: 'foreign-overwrite' }, - 'ambiguous::/work': { parentWorktreeId: 'ambiguous-overwrite' }, - 'contradictory::/work': { parentWorktreeId: 'contradictory-overwrite' } - }, - workspaceLineageByChildKey: { - 'a::/work': { parentWorkspaceKey: 'raw-terminal-key-must-not-enter-lineage' }, - [worktreeWorkspaceKey('a::/work')]: { - parentWorkspaceKey: 'fresh-a-workspace' - }, - [worktreeWorkspaceKey('b::/work')]: { - parentWorkspaceKey: 'foreign-workspace-overwrite' - }, - [worktreeWorkspaceKey('ambiguous::/work')]: { - parentWorkspaceKey: 'ambiguous-workspace-overwrite' - }, - [worktreeWorkspaceKey('contradictory::/work')]: { - parentWorkspaceKey: 'contradictory-workspace-overwrite' - }, - [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'fresh-folder' }, - [folderWorkspaceKey('folder-b')]: { - parentWorkspaceKey: 'foreign-folder-overwrite' - } - } - }) as unknown as HostLineageSnapshot - ), - isCurrentAuthority: () => true - }) - - await expect( - hydration.readHostScopedLineage({ - ...owner, - catalogRevision: 0, - repoRefs: [{ repoId: 'a', executionHostId: 'ssh:target-a' }], - authorityRequirement: 'required', - reason: 'reconnect' - }) - ).resolves.toBe('complete') - expect(store.getState().worktreeLineageById).toEqual({ - 'a::/work': { parentWorktreeId: 'fresh-a' }, - 'b::/work': { parentWorktreeId: 'keep-b' }, - 'ambiguous::/work': { parentWorktreeId: 'keep-ambiguous' }, - 'contradictory::/work': { parentWorktreeId: 'keep-contradictory' } - }) - expect(store.getState().workspaceLineageByChildKey).toEqual({ - [worktreeWorkspaceKey('b::/work')]: { parentWorkspaceKey: 'keep-b-workspace' }, - [worktreeWorkspaceKey('ambiguous::/work')]: { - parentWorkspaceKey: 'keep-ambiguous-workspace' - }, - [worktreeWorkspaceKey('contradictory::/work')]: { - parentWorkspaceKey: 'keep-contradictory-workspace' - }, - [folderWorkspaceKey('folder-b')]: { parentWorkspaceKey: 'keep-folder-b' }, - [worktreeWorkspaceKey('a::/work')]: { parentWorkspaceKey: 'fresh-a-workspace' }, - [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'fresh-folder' } - }) - }) - - it('keeps lineage map identity for a cloned no-op host snapshot', async () => { - const owner = authority() - const hostLineage = productionLineage('a::/work', 'a::/parent') - const foreignLineage = productionLineage('b::/work', 'b::/parent') - const hostWorkspace = productionWorkspaceLineage('a::/work', 'a::/parent') - const foreignWorkspace = productionWorkspaceLineage('b::/work', 'b::/parent') - const store = createStore(() => - state({ - repos: [repo('a', 'target-a'), repo('b', 'target-b')], - worktreesByRepo: { - a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }], - b: [{ id: 'b::/work', repoId: 'b', hostId: 'ssh:target-b' }] - }, - worktreeLineageById: { - 'a::/work': hostLineage, - 'b::/work': foreignLineage - }, - workspaceLineageByChildKey: { - [worktreeWorkspaceKey('a::/work')]: hostWorkspace, - [worktreeWorkspaceKey('b::/work')]: foreignWorkspace - } - }) - ) - const snapshot: HostLineageSnapshot = { - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: 'ssh:target-a', - ...owner - }, - worktreeLineageById: { - 'a::/work': hostLineage - }, - workspaceLineageByChildKey: { - [worktreeWorkspaceKey('a::/work')]: hostWorkspace - } - } - let publications = 0 - store.subscribe(() => { - publications += 1 - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(), - listLineage: vi.fn(async () => structuredClone(snapshot)), - isCurrentAuthority: () => true - }) - const beforeLineage = store.getState().worktreeLineageById - const beforeWorkspace = store.getState().workspaceLineageByChildKey - - await expect( - hydration.readHostScopedLineage({ - ...owner, - catalogRevision: 0, - repoRefs: [{ repoId: 'a', executionHostId: 'ssh:target-a' }], - authorityRequirement: 'required', - reason: 'reconnect' - }) - ).resolves.toBe('complete') - - expect(store.getState().worktreeLineageById).toBe(beforeLineage) - expect(store.getState().workspaceLineageByChildKey).toBe(beforeWorkspace) - expect(store.getState().worktreeLineageById['a::/work']).toBe(hostLineage) - expect(store.getState().worktreeLineageById['b::/work']).toBe(foreignLineage) - expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('a::/work')]).toBe( - hostWorkspace - ) - expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('b::/work')]).toBe( - foreignWorkspace - ) - expect(publications).toBe(0) - }) - - it('rejects lineage captured before a newer same-authority catalog revision', async () => { - const owner = authority() - const store = createStore(() => - state({ - repos: [repo('a', 'target-a')], - worktreesByRepo: { - a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }] - }, - worktreeLineageById: { - 'a::/work': { parentWorktreeId: 'keep-current' } - } - }) - ) - let resolveLineage!: (value: HostLineageSnapshot) => void - const pendingLineage = new Promise((resolve) => { - resolveLineage = resolve - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => hostSnapshot(owner, [repo('a', 'target-a')])), - listLineage: () => pendingLineage, - isCurrentAuthority: () => true - }) - const firstInput = await hydration.capturePreparationInput(owner, 'reconnect') - if (!firstInput) { - throw new Error('Expected first preparation input') - } - const pending = hydration.readHostScopedLineage(firstInput) - await hydration.capturePreparationInput(owner, 'wake-refresh') - const beforeLateLineage = store.getState().worktreeLineageById - resolveLineage({ - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: 'ssh:target-a', - ...owner - }, - worktreeLineageById: { - 'a::/work': { parentWorktreeId: 'stale-overwrite' } - }, - workspaceLineageByChildKey: {} - } as unknown as HostLineageSnapshot) - - await expect(pending).resolves.toBe('stale') - expect(store.getState().worktreeLineageById).toBe(beforeLateLineage) - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.ts deleted file mode 100644 index 32b97b50a55..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.ts +++ /dev/null @@ -1,315 +0,0 @@ -import type { StoreApi } from 'zustand' -import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' -import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' -import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' -import { applyManualRepoOrder } from '../../../shared/manual-repo-order' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { isWorkspaceKey } from '../../../shared/workspace-scope' -import type { AppState } from '../store/types' -import { reconcileFetchedRepos, reuseEqualRecordMap } from '../store/slices/repo-identity-reconcile' -import type { - DirectSshLineageOutcome, - DirectSshPreparationInput, - DirectSshPreparationReason -} from './direct-ssh-reconnect-coordinator' -import { directSshHostHydrationTelemetry } from './direct-ssh-host-hydration-telemetry' -import { directSshHostHydrationScope } from './direct-ssh-host-hydration-scope' -import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' - -export const DIRECT_SSH_HOST_READ_TIMEOUT_MS = 5_000 - -type HostReadTimer = ReturnType - -export type DirectSshHostHydrationDeps = { - store: Pick, 'getState' | 'setState'> - listRepos: (authority: DirectSshAuthority) => Promise - listLineage: (authority: DirectSshAuthority) => Promise - isCurrentAuthority: (authority: DirectSshAuthority) => boolean - setTimer?: (callback: () => void, delayMs: number) => HostReadTimer - clearTimer?: (timer: HostReadTimer) => void -} - -export type DirectSshHostHydration = { - capturePreparationInput: ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason, - snapshotRevision?: number - ) => Promise - readHostScopedLineage: (input: DirectSshPreparationInput) => Promise - isPreparationTokenCurrent: (token: { - authority: DirectSshAuthority - catalogRevision: number - repoFingerprint: string - }) => boolean - stop: () => void -} - -type BoundedResult = { status: 'complete'; value: T } | { status: 'timed-out' | 'unavailable' } - -function isAuthoritativeHost( - authority: HostRepoCatalogSnapshot | HostLineageSnapshot, - expected: DirectSshAuthority -): authority is Extract { - if (!authority.authoritative || authority.authority.kind !== 'direct-ssh') { - return false - } - return ( - authority.authority.executionHostId === toSshExecutionHostId(expected.targetId) && - directSshAuthoritiesEqual(authority.authority, expected) - ) -} - -function mergeExactHostCatalog(state: AppState, snapshot: HostRepoCatalogSnapshot): AppState { - if (!snapshot.authoritative || snapshot.authority.kind !== 'direct-ssh') { - return state - } - const hostId = snapshot.authority.executionHostId - // Why re-apply the overlay: re-appending the host's rows puts them at the tail, which would - // undo the user's manual cross-host order on every connect. - const repos = reconcileFetchedRepos( - state.repos, - applyManualRepoOrder( - [...state.repos.filter((repo) => getRepoExecutionHostId(repo) !== hostId), ...snapshot.repos], - state.manualRepoOrder - ) - ) - return repos === state.repos ? state : { ...state, repos } -} - -// Why: the host owns every in-scope key, so drop the stale in-scope rows, overlay the snapshot's, -// and leave out-of-scope hosts alone. Reusing equal rows keeps a cloned no-op read from republishing. -function overlayHostScopedLineage( - previous: Readonly>, - incoming: Readonly>, - isHostScoped: (key: string) => boolean -): Readonly> { - const next: Record = Object.fromEntries([ - ...Object.entries(previous).filter(([key]) => !isHostScoped(key)), - ...Object.entries(incoming).filter(([key]) => isHostScoped(key)) - ]) - return reuseEqualRecordMap(previous, next) -} - -function mergeExactHostLineage( - state: AppState, - snapshot: Extract, - authority: DirectSshAuthority, - catalogRevision: number -): AppState { - const scope = directSshHostHydrationScope(state, authority, catalogRevision) - const worktreeLineageById = overlayHostScopedLineage( - state.worktreeLineageById, - snapshot.worktreeLineageById, - (worktreeId) => scope.gitWorktreeIds.has(worktreeId) - ) - const workspaceLineageByChildKey = overlayHostScopedLineage( - state.workspaceLineageByChildKey, - snapshot.workspaceLineageByChildKey, - (childKey) => isWorkspaceKey(childKey) && scope.lineageWorkspaceKeys.has(childKey) - ) - if ( - worktreeLineageById === state.worktreeLineageById && - workspaceLineageByChildKey === state.workspaceLineageByChildKey - ) { - return state - } - return { ...state, worktreeLineageById, workspaceLineageByChildKey } -} - -export function createDirectSshHostHydration( - deps: DirectSshHostHydrationDeps -): DirectSshHostHydration { - const setTimer: NonNullable = - deps.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)) - const clearTimer: NonNullable = - deps.clearTimer ?? ((timer) => clearTimeout(timer)) - const catalogRevisionByTarget = new Map() - const catalogInFlight = new Map>() - const pendingDeadlines = new Set<{ timer: HostReadTimer; settle: () => void }>() - let stopped = false - - const bounded = async (operation: Promise): Promise> => { - let pendingDeadline: { timer: HostReadTimer; settle: () => void } | undefined - const deadline = new Promise>((resolve) => { - const settle = (): void => resolve({ status: 'timed-out' }) - const timer = setTimer(settle, DIRECT_SSH_HOST_READ_TIMEOUT_MS) - pendingDeadline = { timer, settle } - pendingDeadlines.add(pendingDeadline) - }) - try { - return await Promise.race([ - operation.then, BoundedResult>( - (value) => ({ status: 'complete', value }), - () => ({ status: 'unavailable' }) - ), - deadline - ]) - } finally { - if (pendingDeadline) { - clearTimer(pendingDeadline.timer) - pendingDeadlines.delete(pendingDeadline) - } - } - } - - const refreshCatalog = ( - authority: DirectSshAuthority - ): Promise<'complete' | 'degraded' | 'stale'> => { - const key = JSON.stringify([ - authority.targetId, - authority.providerEpoch, - authority.connectionGeneration - ]) - const existing = catalogInFlight.get(key) - if (existing) { - return existing - } - const operation = (async (): Promise<'complete' | 'degraded' | 'stale'> => { - const boundedResult = await bounded(Promise.resolve().then(() => deps.listRepos(authority))) - if (stopped || !deps.isCurrentAuthority(authority)) { - return 'stale' - } - if (boundedResult.status !== 'complete') { - return 'degraded' - } - const snapshot = boundedResult.value - if (!snapshot.authoritative) { - return snapshot.reason === 'stale' || snapshot.reason === 'rejected' ? 'stale' : 'degraded' - } - if ( - !isAuthoritativeHost(snapshot, authority) || - snapshot.repos.some( - (repo) => - getRepoExecutionHostId(repo) !== toSshExecutionHostId(authority.targetId) || - repo.connectionId !== authority.targetId - ) - ) { - return 'stale' - } - let admitted = false - deps.store.setState((state) => { - if (stopped || !deps.isCurrentAuthority(authority)) { - return state - } - admitted = true - return mergeExactHostCatalog(state, snapshot) - }) - if (!admitted) { - return 'stale' - } - catalogRevisionByTarget.set( - authority.targetId, - (catalogRevisionByTarget.get(authority.targetId) ?? 0) + 1 - ) - return 'complete' - })().finally(() => { - if (catalogInFlight.get(key) === operation) { - catalogInFlight.delete(key) - } - }) - catalogInFlight.set(key, operation) - return operation - } - - const capturePreparationInput = async ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason, - snapshotRevision?: number - ): Promise => { - const catalogStartedAt = Date.now() - const catalogOutcome = await refreshCatalog(authority) - const catalogDurationMs = Math.max(0, Date.now() - catalogStartedAt) - if (catalogOutcome === 'stale' || stopped || !deps.isCurrentAuthority(authority)) { - return null - } - const catalogRevision = catalogRevisionByTarget.get(authority.targetId) ?? 0 - const scope = directSshHostHydrationScope(deps.store.getState(), authority, catalogRevision) - return { - ...authority, - catalogRevision, - repoRefs: scope.gitRepos, - authorityRequirement: 'required', - ...(snapshotRevision === undefined ? {} : { snapshotRevision }), - reason, - telemetry: directSshHostHydrationTelemetry(scope, catalogOutcome, catalogDurationMs) - } - } - - const readHostScopedLineage = async ( - input: DirectSshPreparationInput - ): Promise => { - const authority = { - targetId: input.targetId, - providerEpoch: input.providerEpoch, - connectionGeneration: input.connectionGeneration - } - const boundedResult = await bounded(Promise.resolve().then(() => deps.listLineage(authority))) - if ( - stopped || - !deps.isCurrentAuthority(authority) || - (catalogRevisionByTarget.get(authority.targetId) ?? 0) !== input.catalogRevision - ) { - return 'stale' - } - if (boundedResult.status !== 'complete') { - return 'degraded' - } - const snapshot = boundedResult.value - if (!snapshot.authoritative) { - return snapshot.reason === 'stale' || snapshot.reason === 'rejected' ? 'stale' : 'degraded' - } - if (!isAuthoritativeHost(snapshot, authority)) { - return 'stale' - } - let admitted = false - deps.store.setState((state) => { - if ( - stopped || - !deps.isCurrentAuthority(authority) || - (catalogRevisionByTarget.get(authority.targetId) ?? 0) !== input.catalogRevision - ) { - return state - } - admitted = true - return mergeExactHostLineage(state, snapshot, authority, input.catalogRevision) - }) - return admitted ? 'complete' : 'stale' - } - - return { - capturePreparationInput, - readHostScopedLineage, - isPreparationTokenCurrent: (token) => { - if ( - stopped || - !deps.isCurrentAuthority(token.authority) || - (catalogRevisionByTarget.get(token.authority.targetId) ?? 0) !== token.catalogRevision - ) { - return false - } - const scope = directSshHostHydrationScope( - deps.store.getState(), - token.authority, - token.catalogRevision - ) - const sortedRepos = [...scope.gitRepos].sort((left, right) => { - const leftKey = `${left.executionHostId}\0${left.repoId}` - const rightKey = `${right.executionHostId}\0${right.repoId}` - return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0 - }) - return ( - JSON.stringify(sortedRepos.map((repo) => [repo.executionHostId, repo.repoId])) === - token.repoFingerprint - ) - }, - stop: () => { - stopped = true - for (const deadline of pendingDeadlines) { - clearTimer(deadline.timer) - deadline.settle() - } - pendingDeadlines.clear() - catalogInFlight.clear() - } - } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts deleted file mode 100644 index 8e5a411eded..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { - DirectSshPreparationMetrics, - DirectSshPreparationOutcome, - DirectSshReconnectOutcome, - DirectSshRepoOutcomeCounts -} from './direct-ssh-reconnect-coordinator-types' - -export function createEmptyDirectSshRepoOutcomeCounts(): DirectSshRepoOutcomeCounts { - return { - complete: 0, - 'non-authoritative': 0, - 'timed-out': 0, - 'cancel-budget-exhausted': 0, - canceled: 0, - stale: 0, - rejected: 0 - } -} - -export function createEmptyDirectSshPreparationMetrics(): DirectSshPreparationMetrics { - return { - queueWaitDurationsMs: [], - providerExecutionDurationsMs: [], - timeoutRetryCount: 0, - locallySettledWaiterCount: 0, - cancelDebtCount: 0, - replacementAdmissionDelayedCount: 0, - schedulerOverlappingJoinCount: 0, - peakLocallyUnsettled: 0, - estimatedLateWorkAllowanceCount: 0, - lineageDurationMs: 0 - } -} - -export function combineDirectSshReconnectOutcome( - prepared: DirectSshPreparationOutcome, - staleBindingsCleared: number, - retriedTerminals: number, - correctedTerminals: number -): DirectSshReconnectOutcome { - return { - ...prepared, - staleBindingsCleared, - retriedTerminals, - correctedTerminals, - stabilizing: false - } -} - -export function createTerminalOnlyDirectSshReconnectOutcome( - status: DirectSshReconnectOutcome['status'], - staleBindingsCleared = 0, - retriedTerminals = 0 -): DirectSshReconnectOutcome { - return { - status, - token: null, - repoOutcomes: createEmptyDirectSshRepoOutcomeCounts(), - lineageOutcome: 'not-started', - metrics: createEmptyDirectSshPreparationMetrics(), - staleBindingsCleared, - retriedTerminals, - correctedTerminals: 0, - stabilizing: status === 'stabilizing' - } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts deleted file mode 100644 index 9924e55d70b..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { DirectSshReconnectTimer } from './direct-ssh-reconnect-coordinator-types' - -export type DirectSshReconnectTargetState = { - authority: DirectSshAuthority - installedAt: number - dampUntil: number | null - timer: DirectSshReconnectTimer | null -} - -export function createDirectSshReconnectTargetState( - authority: DirectSshAuthority, - previous: DirectSshReconnectTargetState | undefined, - installedAt: number, - stabilizationMs: number -): DirectSshReconnectTargetState { - const rotatedRapidly = - previous !== undefined && installedAt - previous.installedAt < stabilizationMs - return { - authority, - installedAt, - dampUntil: rotatedRapidly ? installedAt + stabilizationMs : null, - timer: null - } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts deleted file mode 100644 index 14bc881ced4..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { - DirectSshCoordinatorTelemetry, - DirectSshPreparationInput, - DirectSshPreparationOutcome, - DirectSshPreparationReason, - DirectSshReconnectOutcome -} from './direct-ssh-reconnect-coordinator-types' -import { createTerminalOnlyDirectSshReconnectOutcome } from './direct-ssh-reconnect-coordinator-outcomes' - -type TelemetryOutcome = DirectSshPreparationOutcome | DirectSshReconnectOutcome - -type TelemetryOptions = { - terminalFinalizationDurationMs?: number - staleBindingsCleared?: number - retriedTerminals?: number - correctedTerminals?: number - damped?: boolean - authorityRotationCount?: number - catalogOutcome?: DirectSshCoordinatorTelemetry['catalogOutcome'] - catalogDurationMs?: number -} - -type DirectSshCoordinatorTelemetryReporter = { - report: ( - mode: DirectSshCoordinatorTelemetry['mode'], - input: DirectSshPreparationInput, - outcome: TelemetryOutcome, - startedAt: number, - options?: TelemetryOptions - ) => void - reportWithoutInput: ( - mode: DirectSshCoordinatorTelemetry['mode'], - reason: DirectSshPreparationReason, - outcome: TelemetryOutcome, - startedAt: number, - options?: TelemetryOptions - ) => void -} - -export function createDirectSshCoordinatorTelemetryReporter(args: { - onTelemetry?: (event: DirectSshCoordinatorTelemetry) => void - now: () => number -}): DirectSshCoordinatorTelemetryReporter { - const emit = (event: DirectSshCoordinatorTelemetry): void => { - try { - args.onTelemetry?.(event) - } catch { - // Recovery cannot depend on diagnostics. - } - } - - const report = ( - mode: DirectSshCoordinatorTelemetry['mode'], - input: DirectSshPreparationInput, - outcome: TelemetryOutcome, - startedAt: number, - options: TelemetryOptions = {} - ): void => { - const inputMetrics = input.telemetry - const outcomeMetrics = - outcome.metrics ?? createTerminalOnlyDirectSshReconnectOutcome('stale').metrics! - emit({ - mode, - reason: input.reason, - outcome: outcome.status, - durationMs: Math.max(0, args.now() - startedAt), - staleBindingsCleared: options.staleBindingsCleared ?? 0, - retriedTerminals: options.retriedTerminals ?? 0, - correctedTerminals: options.correctedTerminals ?? 0, - terminalFinalizationDurationMs: options.terminalFinalizationDurationMs ?? 0, - catalogOutcome: options.catalogOutcome ?? inputMetrics?.catalogOutcome ?? 'complete', - catalogDurationMs: options.catalogDurationMs ?? inputMetrics?.catalogDurationMs ?? 0, - gitWorktreeCount: inputMetrics?.gitWorktreeCount ?? 0, - folderWorkspaceCount: inputMetrics?.folderWorkspaceCount ?? 0, - ambiguousOwnerCount: inputMetrics?.ambiguousOwnerCount ?? 0, - contradictoryOwnerCount: inputMetrics?.contradictoryOwnerCount ?? 0, - repoOutcomes: { ...outcome.repoOutcomes }, - lineageOutcome: outcome.lineageOutcome, - queueWaitDurationsMs: [...outcomeMetrics.queueWaitDurationsMs], - providerExecutionDurationsMs: [...outcomeMetrics.providerExecutionDurationsMs], - timeoutRetryCount: outcomeMetrics.timeoutRetryCount, - locallySettledWaiterCount: outcomeMetrics.locallySettledWaiterCount, - cancelDebtCount: outcomeMetrics.cancelDebtCount, - replacementAdmissionDelayedCount: outcomeMetrics.replacementAdmissionDelayedCount, - overlappingJoinCount: outcomeMetrics.schedulerOverlappingJoinCount, - peakLocallyUnsettled: outcomeMetrics.peakLocallyUnsettled, - estimatedLateWorkAllowanceCount: outcomeMetrics.estimatedLateWorkAllowanceCount, - authorityRotationCount: options.authorityRotationCount ?? 0, - damped: options.damped ?? false - }) - } - - const reportWithoutInput: DirectSshCoordinatorTelemetryReporter['reportWithoutInput'] = ( - mode, - reason, - outcome, - startedAt, - options = {} - ) => { - report( - mode, - { - targetId: '', - providerEpoch: '' as DirectSshPreparationInput['providerEpoch'], - connectionGeneration: 0, - catalogRevision: 0, - repoRefs: [], - authorityRequirement: 'required', - reason - }, - outcome, - startedAt, - options - ) - } - - return { report, reportWithoutInput } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts deleted file mode 100644 index 5c3d5b5436b..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { DirectSshGitRepoRef } from '../lib/direct-ssh-target-scope-types' -import type { - DirectSshWorktreeRefreshScheduler, - DirectSshWorktreeRefreshTerminalStatus -} from './direct-ssh-worktree-refresh-scheduler' - -export type DirectSshPreparationReason = - | 'reconnect' - | 'initial-hydration' - | 'workspace-snapshot' - | 'wake-refresh' - -export type DirectSshAuthorityRequirement = 'required' | 'allow-metadata-fallback' - -export type DirectSshPreparationInput = DirectSshAuthority & { - catalogRevision: number - repoRefs: readonly DirectSshGitRepoRef[] - authorityRequirement: DirectSshAuthorityRequirement - snapshotRevision?: number - reason: DirectSshPreparationReason - telemetry?: DirectSshPreparationInputTelemetry -} - -export type DirectSshPreparationInputTelemetry = { - catalogOutcome: 'complete' | 'degraded' - catalogDurationMs: number - gitWorktreeCount: number - folderWorkspaceCount: number - ambiguousOwnerCount: number - contradictoryOwnerCount: number -} - -export type DirectSshPreparationToken = { - authority: DirectSshAuthority - catalogRevision: number - repoFingerprint: string - authorityRequirement: DirectSshAuthorityRequirement - snapshotRevision: number | null - outcome: 'complete' | 'degraded' -} - -export type DirectSshLineageOutcome = 'complete' | 'degraded' | 'canceled' | 'stale' - -export type DirectSshRepoOutcomeCounts = Record - -export type DirectSshPreparationOutcome = { - status: 'complete' | 'degraded' | 'canceled' | 'stale' | 'stopped' - token: DirectSshPreparationToken | null - repoOutcomes: DirectSshRepoOutcomeCounts - lineageOutcome: DirectSshLineageOutcome | 'not-started' - metrics?: DirectSshPreparationMetrics -} - -export type DirectSshPreparationMetrics = { - queueWaitDurationsMs: readonly number[] - providerExecutionDurationsMs: readonly number[] - timeoutRetryCount: number - locallySettledWaiterCount: number - cancelDebtCount: number - replacementAdmissionDelayedCount: number - schedulerOverlappingJoinCount: number - peakLocallyUnsettled: number - estimatedLateWorkAllowanceCount: number - lineageDurationMs: number -} - -export type DirectSshReconnectOutcome = Omit & { - status: DirectSshPreparationOutcome['status'] | 'stabilizing' - staleBindingsCleared: number - retriedTerminals: number - correctedTerminals: number - stabilizing: boolean -} - -export type DirectSshCorrectionReason = - | 'preparation-complete' - | 'wake-refresh' - | 'workspace-hydrated' - | 'snapshot-applied' - -export type DirectSshCoordinatorTelemetry = { - mode: 'reconnect' | 'prepare-only' - reason: DirectSshPreparationReason - outcome: DirectSshReconnectOutcome['status'] - durationMs: number - staleBindingsCleared: number - retriedTerminals: number - correctedTerminals: number - terminalFinalizationDurationMs: number - catalogOutcome: 'complete' | 'degraded' | 'stale' - catalogDurationMs: number - gitWorktreeCount: number - folderWorkspaceCount: number - ambiguousOwnerCount: number - contradictoryOwnerCount: number - repoOutcomes: DirectSshRepoOutcomeCounts - lineageOutcome: DirectSshPreparationOutcome['lineageOutcome'] - queueWaitDurationsMs: readonly number[] - providerExecutionDurationsMs: readonly number[] - timeoutRetryCount: number - locallySettledWaiterCount: number - cancelDebtCount: number - replacementAdmissionDelayedCount: number - overlappingJoinCount: number - peakLocallyUnsettled: number - estimatedLateWorkAllowanceCount: number - authorityRotationCount: number - damped: boolean -} - -export type DirectSshReconnectTimer = ReturnType - -export type DirectSshReconnectCoordinatorDeps = { - scheduler: DirectSshWorktreeRefreshScheduler - isCurrentConnectedAuthority: (authority: DirectSshAuthority) => boolean - capturePreparationInput: ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason - ) => Promise - readHostScopedLineage: (input: DirectSshPreparationInput) => Promise - invalidateStaleTerminalBindings: (authority: DirectSshAuthority) => number - retryTargetPanes: (authority: DirectSshAuthority) => number - correctUnboundTerminalPanes: ( - authority: DirectSshAuthority, - reason: DirectSshCorrectionReason - ) => number - onTelemetry?: (event: DirectSshCoordinatorTelemetry) => void - now?: () => number - setTimer?: (callback: () => void, delayMs: number) => DirectSshReconnectTimer - clearTimer?: (timer: DirectSshReconnectTimer) => void - stabilizationMs?: number -} - -export type DirectSshReconnectCoordinator = { - requestReconnect: (authority: DirectSshAuthority) => Promise - prepareOnly: (input: DirectSshPreparationInput) => Promise - correctUnboundTerminals: ( - authority: DirectSshAuthority, - reason: DirectSshCorrectionReason - ) => number - replaceAuthority: (authority: DirectSshAuthority) => void - invalidate: (targetId: string) => void - stop: () => void -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts deleted file mode 100644 index 7d149a20c58..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { - HostQualifiedDetectedWorktreeResult, - ProviderRequestId -} from '../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { - createDirectSshWorktreeRefreshScheduler, - type DirectSshWorktreeRefreshKey, - type DirectSshWorktreeRefreshLease, - type DirectSshWorktreeRefreshOutcome, - type DirectSshWorktreeRefreshScheduler, - type WaiterLeaseId -} from './direct-ssh-worktree-refresh-scheduler' -import { - createDirectSshReconnectCoordinator, - type DirectSshLineageOutcome, - type DirectSshPreparationInput, - type DirectSshReconnectCoordinatorDeps -} from './direct-ssh-reconnect-coordinator' - -type Deferred = { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, resolve, reject } -} - -function epoch(value: string): SshProviderEpoch { - return value as SshProviderEpoch -} - -function authority(targetId: string, epochId = `epoch-${targetId}`): DirectSshAuthority { - return { - targetId, - providerEpoch: epoch(epochId), - connectionGeneration: 1 - } -} - -function preparationInput( - owner: DirectSshAuthority, - repoIds: readonly string[] = ['repo'], - overrides: Partial = {} -): DirectSshPreparationInput { - return { - ...owner, - catalogRevision: 1, - repoRefs: repoIds.map((repoId) => ({ - repoId, - executionHostId: `ssh:${owner.targetId}` - })), - authorityRequirement: 'required', - reason: 'workspace-snapshot', - ...overrides - } -} - -type FakeLease = { - key: DirectSshWorktreeRefreshKey - deferred: Deferred - lease: DirectSshWorktreeRefreshLease -} - -function createFakeScheduler(): { - scheduler: DirectSshWorktreeRefreshScheduler - leases: FakeLease[] - complete: (index: number, status?: DirectSshWorktreeRefreshOutcome['status']) => void -} { - const leases: FakeLease[] = [] - const request = vi.fn((key: DirectSshWorktreeRefreshKey) => { - const leaseDeferred = deferred() - let released = false - const lease: DirectSshWorktreeRefreshLease = { - waiterLeaseId: `lease-${leases.length + 1}` as WaiterLeaseId, - result: leaseDeferred.promise, - release: vi.fn(() => { - if (!released) { - released = true - leaseDeferred.resolve({ status: 'canceled' }) - } - }) - } - leases.push({ key, deferred: leaseDeferred, lease }) - return lease - }) - const scheduler: DirectSshWorktreeRefreshScheduler = { - request, - invalidateAuthority: vi.fn(), - invalidateTarget: vi.fn(), - disposeProvider: vi.fn(), - getSnapshot: vi.fn(() => ({ - locallyUnsettled: 0, - queued: 0, - retrying: 0, - logicalTasks: leases.length, - waiters: leases.length, - cancelDebtByAuthority: new Map() - })), - stop: vi.fn() - } - return { - scheduler, - leases, - complete: (index, status = 'complete') => leases[index].deferred.resolve({ status }) - } -} - -function createCoordinatorHarness(fakeScheduler = createFakeScheduler()) { - const current = new Map() - const events: string[] = [] - const capturePreparationInput = vi.fn( - async (owner: DirectSshAuthority, reason: DirectSshPreparationInput['reason']) => { - events.push(`capture:${owner.targetId}`) - return preparationInput(owner, ['repo'], { reason }) - } - ) - const deps: DirectSshReconnectCoordinatorDeps = { - scheduler: fakeScheduler.scheduler, - isCurrentConnectedAuthority: (owner) => { - const active = current.get(owner.targetId) - return ( - active?.providerEpoch === owner.providerEpoch && - active.connectionGeneration === owner.connectionGeneration - ) - }, - capturePreparationInput, - readHostScopedLineage: vi.fn(async (): Promise => 'complete'), - invalidateStaleTerminalBindings: vi.fn((owner) => { - events.push(`invalidate:${owner.targetId}`) - return 1 - }), - retryTargetPanes: vi.fn((owner) => { - events.push(`retry:${owner.targetId}`) - return 2 - }), - correctUnboundTerminalPanes: vi.fn(() => 1), - onTelemetry: vi.fn() - } - const coordinator = createDirectSshReconnectCoordinator(deps) - return { coordinator, deps, current, events, ...fakeScheduler } -} - -async function flush(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} - -afterEach(() => { - vi.useRealTimers() -}) - -describe('createDirectSshReconnectCoordinator', () => { - it('runs terminal invalidation and retry synchronously before preparation awaits', () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - - void harness.coordinator.requestReconnect(owner) - - expect(harness.events).toEqual(['invalidate:target-a', 'retry:target-a', 'capture:target-a']) - expect(harness.leases).toHaveLength(0) - }) - - it('prepares the first observed authority immediately', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - - const pending = harness.coordinator.requestReconnect(owner) - await flush() - - expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(owner, 'reconnect') - expect(harness.leases).toHaveLength(1) - harness.complete(0) - await expect(pending).resolves.toMatchObject({ status: 'complete', stabilizing: false }) - }) - - it('keeps target B independent while target A preparation is blocked', async () => { - const harness = createCoordinatorHarness() - const targetA = authority('target-a') - const targetB = authority('target-b') - harness.current.set(targetA.targetId, targetA) - harness.current.set(targetB.targetId, targetB) - - const pendingA = harness.coordinator.requestReconnect(targetA) - await flush() - const pendingB = harness.coordinator.requestReconnect(targetB) - await flush() - expect(harness.leases).toHaveLength(2) - - harness.complete(1) - await expect(pendingB).resolves.toMatchObject({ status: 'complete' }) - expect(harness.deps.correctUnboundTerminalPanes).toHaveBeenCalledTimes(1) - - let targetASettled = false - void pendingA.then(() => { - targetASettled = true - }) - await flush() - expect(targetASettled).toBe(false) - harness.coordinator.stop() - }) - - it('treats exact-equal authority replacement as a true timing no-op', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const harness = createCoordinatorHarness() - const owner = authority('target-a') - - harness.coordinator.replaceAuthority(owner) - vi.setSystemTime(4_000) - harness.coordinator.replaceAuthority({ ...owner }) - - expect(harness.scheduler.disposeProvider).not.toHaveBeenCalled() - expect(harness.scheduler.invalidateTarget).not.toHaveBeenCalled() - - vi.setSystemTime(5_001) - const next = authority('target-a', 'next') - harness.current.set(next.targetId, next) - const pending = harness.coordinator.requestReconnect(next) - await flush() - - expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(next, 'reconnect') - harness.complete(0) - await expect(pending).resolves.toMatchObject({ status: 'complete', stabilizing: false }) - }) - - it('cancels obsolete work on changed authority without ordering epochs', async () => { - const harness = createCoordinatorHarness() - const oldOwner = authority('target-a', 'z-old') - const newOwner = authority('target-a', 'a-new') - harness.current.set(oldOwner.targetId, oldOwner) - harness.coordinator.replaceAuthority(oldOwner) - const pending = harness.coordinator.prepareOnly(preparationInput(oldOwner)) - await flush() - - harness.current.set(newOwner.targetId, newOwner) - harness.coordinator.replaceAuthority(newOwner) - - await expect(pending).resolves.toMatchObject({ status: 'stale', token: null }) - expect(harness.leases[0].lease.release).toHaveBeenCalledWith('invalidated') - expect(harness.scheduler.disposeProvider).toHaveBeenCalledWith(oldOwner) - }) - - it('settles invalidation locally while a late lineage read remains pending', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - const lineage = deferred<'complete'>() - vi.mocked(harness.deps.readHostScopedLineage).mockReturnValue(lineage.promise) - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const pending = harness.coordinator.prepareOnly(preparationInput(owner)) - harness.complete(0) - await flush() - expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() - - harness.coordinator.invalidate(owner.targetId) - await expect(pending).resolves.toMatchObject({ status: 'stale', token: null }) - - lineage.resolve('complete') - await flush() - expect(harness.deps.correctUnboundTerminalPanes).not.toHaveBeenCalled() - }) - - it('joins exact overlapping preparation and reruns after completion', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const input = preparationInput(owner) - - const first = harness.coordinator.prepareOnly(input) - const joined = harness.coordinator.prepareOnly({ - ...input, - repoRefs: input.repoRefs.toReversed() - }) - expect(joined).toBe(first) - expect(harness.leases).toHaveLength(1) - - harness.complete(0) - await expect(first).resolves.toMatchObject({ status: 'complete' }) - const later = harness.coordinator.prepareOnly(input) - expect(later).not.toBe(first) - expect(harness.leases).toHaveLength(2) - harness.coordinator.stop() - }) - - it('keeps prepare-only isolated from terminal retry and correction', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const pending = harness.coordinator.prepareOnly(preparationInput(owner)) - harness.complete(0, 'non-authoritative') - - await expect(pending).resolves.toMatchObject({ - status: 'degraded', - token: { outcome: 'degraded' } - }) - expect(harness.deps.invalidateStaleTerminalBindings).not.toHaveBeenCalled() - expect(harness.deps.retryTargetPanes).not.toHaveBeenCalled() - expect(harness.deps.correctUnboundTerminalPanes).not.toHaveBeenCalled() - }) - - it('rejects repo refs for another SSH host before scheduler admission', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - - await expect( - harness.coordinator.prepareOnly( - preparationInput(owner, ['repo'], { - repoRefs: [{ repoId: 'repo', executionHostId: 'ssh:target-b' }] - }) - ) - ).resolves.toMatchObject({ status: 'stale', token: null }) - - expect(harness.leases).toHaveLength(0) - expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() - }) - - it('holds lineage and token creation through the scheduler timeout retry barrier', async () => { - const attempts: { - id: ProviderRequestId - deferred: Deferred - key: DirectSshWorktreeRefreshKey - }[] = [] - const scheduler = createDirectSshWorktreeRefreshScheduler({ - startAttempt: (attemptKey) => { - const result = deferred() - const id = `provider-${attempts.length + 1}` as ProviderRequestId - attempts.push({ id, deferred: result, key: attemptKey }) - return { providerRequestId: id, result: result.promise, cancel: vi.fn() } - } - }) - const harness = createCoordinatorHarness({ - scheduler, - leases: [], - complete: () => {} - }) - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const pending = harness.coordinator.prepareOnly(preparationInput(owner)) - - attempts[0].deferred.resolve({ - status: 'timed-out', - providerRequestId: attempts[0].id, - executionHostId: attempts[0].key.executionHostId - }) - await flush() - expect(attempts).toHaveLength(2) - expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() - - attempts[1].deferred.resolve({ - status: 'complete', - providerRequestId: attempts[1].id, - repoId: 'repo', - authority: { - kind: 'direct-ssh', - executionHostId: attempts[1].key.executionHostId, - ...owner - }, - result: { repoId: 'repo', authoritative: true, source: 'git', worktrees: [] } - }) - await expect(pending).resolves.toMatchObject({ status: 'complete', token: {} }) - expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() - }) - - it('starts lineage only after every repo is terminal and returns a fenced token', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const pending = harness.coordinator.prepareOnly( - preparationInput(owner, ['repo-b', 'repo-a'], { - catalogRevision: 7, - snapshotRevision: 11 - }) - ) - expect(harness.leases.map((lease) => lease.key.repoId)).toEqual(['repo-a', 'repo-b']) - - harness.complete(0) - await flush() - expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() - harness.complete(1) - - await expect(pending).resolves.toMatchObject({ - status: 'complete', - token: { - authority: owner, - catalogRevision: 7, - snapshotRevision: 11, - outcome: 'complete' - } - }) - expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() - }) - - it('damps rapid rotations to the latest authority while terminal checks stay immediate', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const harness = createCoordinatorHarness() - const initial = authority('target-a', 'initial') - const middle = authority('target-a', 'middle') - const flapping = authority('target-a', 'flapping') - const latest = authority('target-a', 'latest') - harness.current.set(initial.targetId, initial) - harness.coordinator.replaceAuthority(initial) - - vi.setSystemTime(5_001) - harness.current.set(middle.targetId, middle) - const stableReplacement = harness.coordinator.requestReconnect(middle) - await flush() - harness.complete(0) - await expect(stableReplacement).resolves.toMatchObject({ - status: 'complete', - stabilizing: false - }) - - vi.setSystemTime(6_000) - harness.current.set(flapping.targetId, flapping) - await expect(harness.coordinator.requestReconnect(flapping)).resolves.toMatchObject({ - status: 'stabilizing' - }) - vi.setSystemTime(7_000) - harness.current.set(latest.targetId, latest) - await expect(harness.coordinator.requestReconnect(latest)).resolves.toMatchObject({ - status: 'stabilizing' - }) - - expect(harness.deps.retryTargetPanes).toHaveBeenCalledTimes(3) - expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(4_999) - expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(1) - expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(2) - expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(latest, 'reconnect') - expect(harness.deps.capturePreparationInput).not.toHaveBeenCalledWith(flapping, 'reconnect') - harness.complete(1) - }) - - it('corrects only current authority and cleans pending work and timers on stop', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const harness = createCoordinatorHarness() - const initial = authority('target-a', 'initial') - const latest = authority('target-a', 'latest') - harness.current.set(initial.targetId, initial) - harness.coordinator.replaceAuthority(initial) - harness.current.set(latest.targetId, latest) - void harness.coordinator.requestReconnect(latest) - - expect(harness.coordinator.correctUnboundTerminals(latest, 'wake-refresh')).toBe(1) - harness.coordinator.stop() - await vi.advanceTimersByTimeAsync(5_000) - - expect(harness.deps.capturePreparationInput).not.toHaveBeenCalled() - expect(harness.scheduler.stop).toHaveBeenCalledOnce() - expect(harness.coordinator.correctUnboundTerminals(latest, 'wake-refresh')).toBe(0) - }) - - it('emits one aggregate for joined work with scheduler and scope metrics', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const input = preparationInput(owner, ['repo'], { - telemetry: { - catalogOutcome: 'degraded', - catalogDurationMs: 12, - gitWorktreeCount: 3, - folderWorkspaceCount: 2, - ambiguousOwnerCount: 1, - contradictoryOwnerCount: 1 - } - }) - - const first = harness.coordinator.prepareOnly(input) - const joined = harness.coordinator.prepareOnly(input) - harness.leases[0].deferred.resolve({ - status: 'complete', - metrics: { - queueWaitDurationsMs: [8], - providerExecutionDurationsMs: [13], - timeoutRetryCount: 1, - locallySettledWaiterCount: 1, - cancelDebtCount: 1, - replacementAdmissionDelayedCount: 1, - overlappingJoinCount: 2, - peakLocallyUnsettled: 4, - estimatedLateWorkAllowanceCount: 1 - } - }) - await Promise.all([first, joined]) - - expect(harness.deps.onTelemetry).toHaveBeenCalledOnce() - expect(harness.deps.onTelemetry).toHaveBeenCalledWith( - expect.objectContaining({ - mode: 'prepare-only', - catalogOutcome: 'degraded', - catalogDurationMs: 12, - gitWorktreeCount: 3, - folderWorkspaceCount: 2, - queueWaitDurationsMs: [8], - providerExecutionDurationsMs: [13], - timeoutRetryCount: 1, - cancelDebtCount: 1, - replacementAdmissionDelayedCount: 1, - overlappingJoinCount: 3, - peakLocallyUnsettled: 4 - }) - ) - }) - - it('does not let a telemetry callback failure affect recovery', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - vi.mocked(harness.deps.onTelemetry!).mockImplementation(() => { - throw new Error('telemetry unavailable') - }) - - const pending = harness.coordinator.requestReconnect(owner) - await flush() - harness.complete(0) - - await expect(pending).resolves.toMatchObject({ status: 'complete' }) - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts deleted file mode 100644 index e46f4063c41..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { - createDirectSshPreparationCoordinator, - type DirectSshPreparationCoordinator -} from './direct-ssh-reconnect-preparation' -import { - combineDirectSshReconnectOutcome, - createEmptyDirectSshRepoOutcomeCounts, - createTerminalOnlyDirectSshReconnectOutcome -} from './direct-ssh-reconnect-coordinator-outcomes' -import { - createDirectSshReconnectTargetState, - type DirectSshReconnectTargetState -} from './direct-ssh-reconnect-coordinator-stabilization' -import { createDirectSshCoordinatorTelemetryReporter } from './direct-ssh-reconnect-coordinator-telemetry' -import type { - DirectSshCorrectionReason, - DirectSshPreparationInput, - DirectSshPreparationOutcome, - DirectSshPreparationReason, - DirectSshReconnectCoordinator, - DirectSshReconnectCoordinatorDeps, - DirectSshReconnectOutcome, - DirectSshReconnectTimer -} from './direct-ssh-reconnect-coordinator-types' -import { - directSshAuthoritiesEqual, - isDirectSshPreparationInputHostConsistent, - normalizeDirectSshPreparationInput -} from './direct-ssh-reconnect-tokens' -export type * from './direct-ssh-reconnect-coordinator-types' - -export const DIRECT_SSH_RELAY_STABILIZATION_MS = 5_000 - -export function createDirectSshReconnectCoordinator( - deps: DirectSshReconnectCoordinatorDeps -): DirectSshReconnectCoordinator { - const now = deps.now ?? Date.now - const setTimer = - deps.setTimer ?? ((callback: () => void, delayMs: number) => setTimeout(callback, delayMs)) - const clearTimer = deps.clearTimer ?? ((timer: DirectSshReconnectTimer) => clearTimeout(timer)) - const stabilizationMs = deps.stabilizationMs ?? DIRECT_SSH_RELAY_STABILIZATION_MS - const targets = new Map() - let stopped = false - - const isCurrent = (authority: DirectSshAuthority): boolean => { - const state = targets.get(authority.targetId) - return ( - !stopped && - directSshAuthoritiesEqual(state?.authority, authority) && - deps.isCurrentConnectedAuthority(authority) - ) - } - - const preparation: DirectSshPreparationCoordinator = createDirectSshPreparationCoordinator({ - scheduler: deps.scheduler, - isCurrentAuthority: isCurrent, - readLineage: deps.readHostScopedLineage, - now - }) - const telemetry = createDirectSshCoordinatorTelemetryReporter({ - onTelemetry: deps.onTelemetry, - now - }) - - const replaceAuthority = (authority: DirectSshAuthority): boolean => { - if (stopped) { - return false - } - const previous = targets.get(authority.targetId) - if (directSshAuthoritiesEqual(previous?.authority, authority)) { - return false - } - if (previous) { - if (previous.timer) { - clearTimer(previous.timer) - } - preparation.invalidateAuthority(previous.authority) - deps.scheduler.disposeProvider(previous.authority) - } - const installedAt = now() - targets.set( - authority.targetId, - createDirectSshReconnectTargetState(authority, previous, installedAt, stabilizationMs) - ) - return previous !== undefined - } - - const captureInput = async ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason - ): Promise => { - const captured = await deps.capturePreparationInput(authority, reason) - if ( - !captured || - !directSshAuthoritiesEqual(captured, authority) || - !isDirectSshPreparationInputHostConsistent(captured) || - !isCurrent(authority) - ) { - return null - } - return normalizeDirectSshPreparationInput({ ...captured, reason }) - } - - const runPreparedReconnect = async ( - authority: DirectSshAuthority, - staleBindingsCleared: number, - retriedTerminals: number, - damped: boolean, - operationStartedAt = now(), - terminalFinalizationDurationMs = 0, - authorityRotationCount = 0 - ): Promise => { - const input = await captureInput(authority, 'reconnect') - if (!input) { - const outcome = createTerminalOnlyDirectSshReconnectOutcome( - 'stale', - staleBindingsCleared, - retriedTerminals - ) - telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, operationStartedAt, { - staleBindingsCleared, - retriedTerminals, - terminalFinalizationDurationMs, - catalogOutcome: 'stale', - catalogDurationMs: Math.max(0, now() - operationStartedAt), - authorityRotationCount, - damped - }) - return outcome - } - const acquired = preparation.acquire(input) - const prepared = await acquired.promise - let correctedTerminals = 0 - if (prepared.token && isCurrent(authority)) { - correctedTerminals = deps.correctUnboundTerminalPanes(authority, 'preparation-complete') - } - const outcome = combineDirectSshReconnectOutcome( - prepared, - staleBindingsCleared, - retriedTerminals, - correctedTerminals - ) - if (!acquired.joined) { - telemetry.report('reconnect', input, prepared, operationStartedAt, { - terminalFinalizationDurationMs, - staleBindingsCleared, - retriedTerminals, - correctedTerminals, - damped, - authorityRotationCount - }) - } - return outcome - } - - const runDelayedPreparation = async (authority: DirectSshAuthority): Promise => { - if (!isCurrent(authority)) { - return - } - await runPreparedReconnect(authority, 0, 0, true, now(), 0, 1) - } - - const scheduleLatestPreparation = (state: DirectSshReconnectTargetState): void => { - if (state.timer || state.dampUntil === null) { - return - } - const delayMs = Math.max(0, state.dampUntil - now()) - state.timer = setTimer(() => { - state.timer = null - state.dampUntil = null - void runDelayedPreparation(state.authority) - }, delayMs) - } - - const requestReconnect = async ( - authority: DirectSshAuthority - ): Promise => { - const startedAt = now() - if (stopped || !deps.isCurrentConnectedAuthority(authority)) { - const outcome = createTerminalOnlyDirectSshReconnectOutcome(stopped ? 'stopped' : 'stale') - telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { - catalogOutcome: 'degraded' - }) - return outcome - } - const rotated = replaceAuthority(authority) - if (!isCurrent(authority)) { - const outcome = createTerminalOnlyDirectSshReconnectOutcome('stale') - telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { - authorityRotationCount: rotated ? 1 : 0 - }) - return outcome - } - const terminalStartedAt = now() - const staleBindingsCleared = deps.invalidateStaleTerminalBindings(authority) - const retriedTerminals = deps.retryTargetPanes(authority) - const terminalFinalizationDurationMs = Math.max(0, now() - terminalStartedAt) - const state = targets.get(authority.targetId)! - if (state.dampUntil !== null && now() < state.dampUntil) { - scheduleLatestPreparation(state) - const outcome = createTerminalOnlyDirectSshReconnectOutcome( - 'stabilizing', - staleBindingsCleared, - retriedTerminals - ) - telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { - staleBindingsCleared, - retriedTerminals, - terminalFinalizationDurationMs, - catalogOutcome: 'degraded', - authorityRotationCount: rotated ? 1 : 0, - damped: true - }) - return outcome - } - return runPreparedReconnect( - authority, - staleBindingsCleared, - retriedTerminals, - false, - startedAt, - terminalFinalizationDurationMs, - rotated ? 1 : 0 - ) - } - - const prepareOnly = ( - rawInput: DirectSshPreparationInput - ): Promise => { - const input = normalizeDirectSshPreparationInput(rawInput) - if (stopped || !isCurrent(input) || !isDirectSshPreparationInputHostConsistent(input)) { - const outcome: DirectSshPreparationOutcome = { - status: stopped ? 'stopped' : 'stale', - token: null, - repoOutcomes: createEmptyDirectSshRepoOutcomeCounts(), - lineageOutcome: 'not-started', - metrics: createTerminalOnlyDirectSshReconnectOutcome('stale').metrics - } - telemetry.report('prepare-only', input, outcome, now()) - return Promise.resolve(outcome) - } - const startedAt = now() - const acquired = preparation.acquire(input) - if (!acquired.joined) { - void acquired.promise.then((outcome) => { - telemetry.report('prepare-only', input, outcome, startedAt) - }) - } - return acquired.promise - } - - const correctUnboundTerminals = ( - authority: DirectSshAuthority, - reason: DirectSshCorrectionReason - ): number => (isCurrent(authority) ? deps.correctUnboundTerminalPanes(authority, reason) : 0) - - const invalidate = (targetId: string): void => { - const state = targets.get(targetId) - if (state?.timer) { - clearTimer(state.timer) - } - preparation.invalidateTarget(targetId) - deps.scheduler.invalidateTarget(targetId) - targets.delete(targetId) - } - - const stop = (): void => { - if (stopped) { - return - } - stopped = true - for (const state of targets.values()) { - if (state.timer) { - clearTimer(state.timer) - } - } - preparation.stop() - deps.scheduler.stop() - targets.clear() - } - - return { - requestReconnect, - prepareOnly, - correctUnboundTerminals, - replaceAuthority, - invalidate, - stop - } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts b/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts deleted file mode 100644 index 6068319fc32..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DirectSshWorktreeRefreshOutcome } from './direct-ssh-worktree-refresh-scheduler' -import type { DirectSshPreparationMetrics } from './direct-ssh-reconnect-coordinator-types' -import { createEmptyDirectSshPreparationMetrics } from './direct-ssh-reconnect-coordinator-outcomes' - -export function aggregateDirectSshPreparationMetrics( - outcomes: readonly DirectSshWorktreeRefreshOutcome[], - joinCount: number -): DirectSshPreparationMetrics { - const aggregate = createEmptyDirectSshPreparationMetrics() - for (const outcome of outcomes) { - const metrics = outcome.metrics - if (!metrics) { - continue - } - aggregate.queueWaitDurationsMs = [ - ...aggregate.queueWaitDurationsMs, - ...metrics.queueWaitDurationsMs - ] - aggregate.providerExecutionDurationsMs = [ - ...aggregate.providerExecutionDurationsMs, - ...metrics.providerExecutionDurationsMs - ] - aggregate.timeoutRetryCount += metrics.timeoutRetryCount - aggregate.locallySettledWaiterCount += metrics.locallySettledWaiterCount - aggregate.cancelDebtCount += metrics.cancelDebtCount - aggregate.replacementAdmissionDelayedCount += metrics.replacementAdmissionDelayedCount - aggregate.schedulerOverlappingJoinCount += metrics.overlappingJoinCount - aggregate.peakLocallyUnsettled = Math.max( - aggregate.peakLocallyUnsettled, - metrics.peakLocallyUnsettled - ) - aggregate.estimatedLateWorkAllowanceCount = Math.max( - aggregate.estimatedLateWorkAllowanceCount, - metrics.estimatedLateWorkAllowanceCount - ) - } - aggregate.schedulerOverlappingJoinCount += joinCount - return aggregate -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts b/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts deleted file mode 100644 index e18dced3112..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { - DirectSshWorktreeRefreshLease, - DirectSshWorktreeRefreshOutcome, - DirectSshWorktreeRefreshScheduler, - DirectSshWorktreeRefreshTerminalStatus -} from './direct-ssh-worktree-refresh-scheduler' -import type { - DirectSshLineageOutcome, - DirectSshPreparationInput, - DirectSshPreparationOutcome, - DirectSshPreparationToken, - DirectSshRepoOutcomeCounts -} from './direct-ssh-reconnect-coordinator-types' -import { - createEmptyDirectSshPreparationMetrics, - createEmptyDirectSshRepoOutcomeCounts -} from './direct-ssh-reconnect-coordinator-outcomes' -import { aggregateDirectSshPreparationMetrics } from './direct-ssh-reconnect-preparation-metrics' -import { - directSshAuthoritiesEqual, - directSshPreparationOperationKey, - directSshRepoFingerprint, - normalizeDirectSshPreparationInput -} from './direct-ssh-reconnect-tokens' - -type PreparationOperation = { - key: string - input: DirectSshPreparationInput - leases: DirectSshWorktreeRefreshLease[] - joinCount: number - invalidatedAs: 'stale' | 'stopped' | null - settleInvalidation: (reason: 'stale' | 'stopped') => void - invalidation: Promise<'stale' | 'stopped'> - promise: Promise -} - -export type DirectSshPreparationCoordinator = { - acquire: (input: DirectSshPreparationInput) => { - promise: Promise - joined: boolean - } - invalidateAuthority: (authority: DirectSshAuthority) => void - invalidateTarget: (targetId: string) => void - stop: () => void -} - -type PreparationCoordinatorDeps = { - scheduler: DirectSshWorktreeRefreshScheduler - isCurrentAuthority: (authority: DirectSshAuthority) => boolean - readLineage: (input: DirectSshPreparationInput) => Promise - now?: () => number -} - -const TERMINAL_STATUSES: readonly DirectSshWorktreeRefreshTerminalStatus[] = [ - 'complete', - 'non-authoritative', - 'timed-out', - 'cancel-budget-exhausted', - 'canceled', - 'stale', - 'rejected' -] - -function countRepoOutcomes( - outcomes: readonly DirectSshWorktreeRefreshOutcome[] -): DirectSshRepoOutcomeCounts { - const counts = createEmptyDirectSshRepoOutcomeCounts() - for (const outcome of outcomes) { - counts[outcome.status]++ - } - return counts -} - -function awaitRepoLeases( - leases: readonly DirectSshWorktreeRefreshLease[] -): Promise { - if (leases.length === 0) { - return Promise.resolve([]) - } - return new Promise((resolve) => { - const outcomes: DirectSshWorktreeRefreshOutcome[] = [] - let remaining = leases.length - leases.forEach((lease, index) => { - void lease.result.then( - (outcome) => { - outcomes[index] = outcome - remaining-- - if (remaining === 0) { - resolve(outcomes) - } - }, - () => { - outcomes[index] = { status: 'rejected' } - remaining-- - if (remaining === 0) { - resolve(outcomes) - } - } - ) - }) - }) -} - -function terminalPreparationOutcome( - status: 'canceled' | 'stale' | 'stopped', - repoOutcomes: DirectSshRepoOutcomeCounts, - metrics = createEmptyDirectSshPreparationMetrics() -): DirectSshPreparationOutcome { - return { - status, - token: null, - repoOutcomes, - lineageOutcome: 'not-started', - metrics - } -} - -function buildPreparationToken( - input: DirectSshPreparationInput, - outcome: 'complete' | 'degraded' -): DirectSshPreparationToken { - return { - authority: { - targetId: input.targetId, - providerEpoch: input.providerEpoch, - connectionGeneration: input.connectionGeneration - }, - catalogRevision: input.catalogRevision, - repoFingerprint: directSshRepoFingerprint(input), - authorityRequirement: input.authorityRequirement, - snapshotRevision: input.snapshotRevision ?? null, - outcome - } -} - -function hasOutcome( - counts: DirectSshRepoOutcomeCounts, - statuses: readonly DirectSshWorktreeRefreshTerminalStatus[] -): boolean { - return statuses.some((status) => counts[status] > 0) -} - -export function createDirectSshPreparationCoordinator( - deps: PreparationCoordinatorDeps -): DirectSshPreparationCoordinator { - const now = deps.now ?? Date.now - const operations = new Map() - let stopped = false - - const run = async (operation: PreparationOperation): Promise => { - const { input } = operation - operation.leases = input.repoRefs.map((repo) => - deps.scheduler.request({ - targetId: input.targetId, - providerEpoch: input.providerEpoch, - connectionGeneration: input.connectionGeneration, - repoId: repo.repoId, - executionHostId: repo.executionHostId, - catalogRevision: input.catalogRevision, - authorityRequirement: input.authorityRequirement - }) - ) - const outcomes = await awaitRepoLeases(operation.leases) - const repoOutcomes = countRepoOutcomes(outcomes) - const metrics = aggregateDirectSshPreparationMetrics(outcomes, operation.joinCount) - if (operation.invalidatedAs) { - return terminalPreparationOutcome(operation.invalidatedAs, repoOutcomes, metrics) - } - if (!deps.isCurrentAuthority(input)) { - return terminalPreparationOutcome('stale', repoOutcomes, metrics) - } - if (hasOutcome(repoOutcomes, ['canceled', 'stale'])) { - const status = repoOutcomes.stale > 0 ? 'stale' : 'canceled' - return terminalPreparationOutcome(status, repoOutcomes, metrics) - } - - let lineageOutcome: DirectSshLineageOutcome - const lineageStartedAt = now() - try { - lineageOutcome = await deps.readLineage(input) - } catch { - lineageOutcome = 'degraded' - } - metrics.lineageDurationMs = Math.max(0, now() - lineageStartedAt) - if (operation.invalidatedAs) { - return terminalPreparationOutcome(operation.invalidatedAs, repoOutcomes, metrics) - } - if (!deps.isCurrentAuthority(input) || lineageOutcome === 'stale') { - return terminalPreparationOutcome('stale', repoOutcomes, metrics) - } - if (lineageOutcome === 'canceled') { - return terminalPreparationOutcome('canceled', repoOutcomes, metrics) - } - const degraded = - lineageOutcome === 'degraded' || - TERMINAL_STATUSES.some((status) => status !== 'complete' && repoOutcomes[status] > 0) - const status = degraded ? 'degraded' : 'complete' - return { - status, - token: buildPreparationToken(input, status), - repoOutcomes, - lineageOutcome, - metrics - } - } - - const acquire = ( - rawInput: DirectSshPreparationInput - ): { promise: Promise; joined: boolean } => { - const input = normalizeDirectSshPreparationInput(rawInput) - const key = directSshPreparationOperationKey(input) - const current = operations.get(key) - if (current) { - current.joinCount++ - return { promise: current.promise, joined: true } - } - if (stopped) { - return { - promise: Promise.resolve( - terminalPreparationOutcome('stopped', createEmptyDirectSshRepoOutcomeCounts()) - ), - joined: false - } - } - const operation: PreparationOperation = { - key, - input, - leases: [], - joinCount: 0, - invalidatedAs: null, - settleInvalidation: () => {}, - invalidation: Promise.resolve('stopped'), - promise: Promise.resolve( - terminalPreparationOutcome('stopped', createEmptyDirectSshRepoOutcomeCounts()) - ) - } - operation.invalidation = new Promise((resolve) => { - operation.settleInvalidation = resolve - }) - operations.set(key, operation) - operation.promise = Promise.race([ - run(operation), - operation.invalidation.then((reason) => - terminalPreparationOutcome(reason, createEmptyDirectSshRepoOutcomeCounts()) - ) - ]).finally(() => { - if (operations.get(key) === operation) { - operations.delete(key) - } - }) - return { promise: operation.promise, joined: false } - } - - const invalidateMatching = ( - predicate: (operation: PreparationOperation) => boolean, - reason: 'stale' | 'stopped' - ): void => { - for (const operation of operations.values()) { - if (!predicate(operation)) { - continue - } - operation.invalidatedAs = reason - operation.settleInvalidation(reason) - for (const lease of operation.leases) { - lease.release(reason === 'stopped' ? 'stopped' : 'invalidated') - } - } - } - - const invalidateAuthority = (authority: DirectSshAuthority): void => { - invalidateMatching( - (operation) => directSshAuthoritiesEqual(operation.input, authority), - 'stale' - ) - } - - const invalidateTarget = (targetId: string): void => { - invalidateMatching((operation) => operation.input.targetId === targetId, 'stale') - } - - const stop = (): void => { - if (stopped) { - return - } - stopped = true - invalidateMatching(() => true, 'stopped') - } - - return { acquire, invalidateAuthority, invalidateTarget, stop } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts deleted file mode 100644 index a835ea81856..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY, - isDirectSshReconnectCoordinatorRoutingEnabled, - resolveDirectSshReconnectCoordinatorRouting -} from './direct-ssh-reconnect-rollout' - -describe('direct SSH reconnect rollout', () => { - it('defaults coordinator routing on', () => { - expect(resolveDirectSshReconnectCoordinatorRouting({})).toBe(true) - expect(resolveDirectSshReconnectCoordinatorRouting({ buildValue: 'true' })).toBe(true) - }) - - it('supports build and session disable routes without session re-enablement', () => { - expect( - resolveDirectSshReconnectCoordinatorRouting({ - buildValue: 'false', - sessionValue: 'true' - }) - ).toBe(false) - expect( - resolveDirectSshReconnectCoordinatorRouting({ - buildValue: 'true', - sessionValue: 'false' - }) - ).toBe(false) - }) - - it('reads the current session route and fails open when storage is unavailable', () => { - const getItem = vi.fn(() => 'false') - vi.stubGlobal('sessionStorage', { getItem }) - - expect(isDirectSshReconnectCoordinatorRoutingEnabled()).toBe(false) - expect(getItem).toHaveBeenCalledWith(DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY) - - getItem.mockImplementation(() => { - throw new Error('blocked') - }) - expect(isDirectSshReconnectCoordinatorRoutingEnabled()).toBe(true) - vi.unstubAllGlobals() - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts deleted file mode 100644 index aa8645cb7ba..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY = 'orca.directSshReconnectCoordinator.enabled' - -export function resolveDirectSshReconnectCoordinatorRouting(args: { - buildValue?: string - sessionValue?: string | null -}): boolean { - if (args.buildValue?.trim().toLowerCase() === 'false') { - return false - } - return args.sessionValue?.trim().toLowerCase() !== 'false' -} - -export function isDirectSshReconnectCoordinatorRoutingEnabled(): boolean { - let sessionValue: string | null = null - try { - sessionValue = - globalThis.sessionStorage?.getItem(DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY) ?? null - } catch { - // Storage can be unavailable in privacy-restricted renderer sessions. - } - return resolveDirectSshReconnectCoordinatorRouting({ - buildValue: import.meta.env.VITE_DIRECT_SSH_RECONNECT_COORDINATOR, - sessionValue - }) -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts deleted file mode 100644 index 98790b7362e..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' - -const authority: DirectSshAuthority = { - targetId: 'target-a', - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 1 -} - -describe('directSshAuthoritiesEqual', () => { - it('fails closed when either authority is missing', () => { - expect(directSshAuthoritiesEqual(null, null)).toBe(false) - expect(directSshAuthoritiesEqual(authority, null)).toBe(false) - expect(directSshAuthoritiesEqual(undefined, authority)).toBe(false) - }) - - it('requires the complete authority tuple', () => { - expect(directSshAuthoritiesEqual(authority, { ...authority })).toBe(true) - expect( - directSshAuthoritiesEqual(authority, { - ...authority, - connectionGeneration: authority.connectionGeneration + 1 - }) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts deleted file mode 100644 index 58e666a457a..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { DirectSshPreparationInput } from './direct-ssh-reconnect-coordinator-types' - -export function directSshAuthoritiesEqual( - left: DirectSshAuthority | null | undefined, - right: DirectSshAuthority | null | undefined -): boolean { - if (!left || !right) { - return false - } - return ( - left.targetId === right.targetId && - left.providerEpoch === right.providerEpoch && - left.connectionGeneration === right.connectionGeneration - ) -} - -export function normalizeDirectSshPreparationInput( - input: DirectSshPreparationInput -): DirectSshPreparationInput { - return { - ...input, - repoRefs: [...input.repoRefs].sort( - (left, right) => - compareText(left.executionHostId, right.executionHostId) || - compareText(left.repoId, right.repoId) - ) - } -} - -function compareText(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} - -export function isDirectSshPreparationInputHostConsistent( - input: DirectSshPreparationInput -): boolean { - const expectedHost = toSshExecutionHostId(input.targetId) - return input.repoRefs.every((repo) => repo.executionHostId === expectedHost) -} - -export function directSshRepoFingerprint(input: DirectSshPreparationInput): string { - return JSON.stringify(input.repoRefs.map((repo) => [repo.executionHostId, repo.repoId])) -} - -export function directSshPreparationOperationKey(input: DirectSshPreparationInput): string { - return JSON.stringify([ - input.targetId, - input.providerEpoch, - input.connectionGeneration, - input.catalogRevision, - directSshRepoFingerprint(input), - input.authorityRequirement, - input.snapshotRevision ?? null, - input.reason - ]) -} diff --git a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx index e2d8a015dc1..4da89ed8686 100644 --- a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx +++ b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx @@ -142,16 +142,13 @@ describe('runtime and direct SSH wake isolation', () => { ] ]) }) - const staleAuthority = { ...currentAuthority, connectionGeneration: 6 } - expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(staleAuthority)).toBe(0) - expect(store.getState().tabsByWorktree[directWorktreeId][0].ptyId).toBe(directPtyId) const wakePreparation = vi.fn() const correctedCounts: number[] = [] const unregisterDirectWake = registerDirectSshWakeRouting({ getConnectionStates: () => store.getState().sshConnectionStates, wakeAuthority: (nextAuthority) => { correctedCounts.push( - store.getState().invalidateStaleDirectSshTargetPtyBindings(nextAuthority) + store.getState().clearDirectSshTargetPtyBindings(nextAuthority.targetId) ) wakePreparation(nextAuthority) }, diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.test.ts b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts index e59014adb2e..673fd0e2d6c 100644 --- a/src/renderer/src/hooks/direct-ssh-state-routing.test.ts +++ b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts @@ -3,8 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' import { directSshAuthorityFromConnectionState, - registerDirectSshWakeRouting, - routeDirectSshConnectedState + registerDirectSshWakeRouting } from './direct-ssh-state-routing' function authority(epoch = 'epoch-a'): DirectSshAuthority { @@ -15,109 +14,6 @@ function authority(epoch = 'epoch-a'): DirectSshAuthority { } } -function harness() { - return { - coordinatorRoutingEnabled: true, - coordinator: { - requestReconnect: vi.fn(async () => ({ status: 'complete' })), - correctUnboundTerminals: vi.fn(() => 0), - replaceAuthority: vi.fn() - }, - invalidateStaleTerminalBindings: vi.fn(() => 0), - retryTargetPanes: vi.fn(() => 0), - prepareHost: vi.fn(), - rememberReconnectAuthority: vi.fn() - } -} - -describe('routeDirectSshConnectedState', () => { - it('keeps initial hydration preparation-only', () => { - const deps = harness() - const current = authority() - - expect( - routeDirectSshConnectedState(deps, { - authority: current, - previousAuthority: null, - origin: 'initial-hydration' - }) - ).toBe('initial-hydration') - expect(deps.prepareHost).toHaveBeenCalledWith(current, 'initial-hydration') - expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() - expect(deps.coordinator.correctUnboundTerminals).not.toHaveBeenCalled() - }) - - it('requests reconnect only when the complete pushed authority changes', () => { - const deps = harness() - const previous = authority('epoch-old') - const current = authority('epoch-new') - - expect( - routeDirectSshConnectedState(deps, { - authority: current, - previousAuthority: previous, - origin: 'push' - }) - ).toBe('changed-authority') - expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) - expect(deps.coordinator.requestReconnect).toHaveBeenCalledWith(current) - expect(deps.coordinator.replaceAuthority).not.toHaveBeenCalled() - expect(deps.prepareHost).not.toHaveBeenCalled() - }) - - it('runs the disabled fallback terminal actions synchronously before bounded preparation', () => { - const order: string[] = [] - const deps = harness() - deps.coordinatorRoutingEnabled = false - deps.coordinator.replaceAuthority.mockImplementation(() => order.push('replace')) - deps.invalidateStaleTerminalBindings.mockImplementation(() => { - order.push('invalidate') - return 1 - }) - deps.retryTargetPanes.mockImplementation(() => { - order.push('retry') - return 1 - }) - deps.prepareHost.mockImplementation(() => { - order.push('prepare') - return new Promise(() => {}) - }) - const previous = authority('epoch-old') - const current = authority('epoch-new') - - expect( - routeDirectSshConnectedState(deps, { - authority: current, - previousAuthority: previous, - origin: 'push' - }) - ).toBe('changed-authority-fallback') - - expect(order).toEqual(['replace', 'invalidate', 'retry', 'prepare']) - expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() - expect(deps.prepareHost).toHaveBeenCalledWith(current, 'reconnect', { - authorityAlreadyReplaced: true - }) - expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) - }) - - it('routes an exact-equal push through correction and fresh preparation', () => { - const deps = harness() - const current = authority() - - expect( - routeDirectSshConnectedState(deps, { - authority: current, - previousAuthority: { ...current }, - origin: 'push' - }) - ).toBe('same-authority-wake') - expect(deps.coordinator.correctUnboundTerminals).toHaveBeenCalledWith(current, 'wake-refresh') - expect(deps.prepareHost).toHaveBeenCalledWith(current, 'wake-refresh') - expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() - }) -}) - describe('registerDirectSshWakeRouting', () => { it('wakes only complete connected direct authorities and removes both listeners', () => { const wakeAuthority = vi.fn() diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.ts b/src/renderer/src/hooks/direct-ssh-state-routing.ts index b9ae9f4f607..1cdb1b27c08 100644 --- a/src/renderer/src/hooks/direct-ssh-state-routing.ts +++ b/src/renderer/src/hooks/direct-ssh-state-routing.ts @@ -1,62 +1,7 @@ import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types' -import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' export type DirectSshConnectedStateOrigin = 'push' | 'initial-hydration' -export type DirectSshConnectedStateRoutingDeps = { - coordinator: { - requestReconnect: (authority: DirectSshAuthority) => Promise - correctUnboundTerminals: (authority: DirectSshAuthority, reason: 'wake-refresh') => number - replaceAuthority: (authority: DirectSshAuthority) => void - } - coordinatorRoutingEnabled: boolean - invalidateStaleTerminalBindings: (authority: DirectSshAuthority) => number - retryTargetPanes: (authority: DirectSshAuthority) => number - prepareHost: ( - authority: DirectSshAuthority, - reason: 'reconnect' | 'initial-hydration' | 'wake-refresh', - options?: { authorityAlreadyReplaced?: boolean } - ) => void | Promise - rememberReconnectAuthority: (authority: DirectSshAuthority | null) => void -} - -export type DirectSshConnectedStateRoute = - | 'initial-hydration' - | 'changed-authority' - | 'changed-authority-fallback' - | 'same-authority-wake' - -export function routeDirectSshConnectedState( - deps: DirectSshConnectedStateRoutingDeps, - input: { - authority: DirectSshAuthority - previousAuthority: DirectSshAuthority | null - origin: DirectSshConnectedStateOrigin - } -): DirectSshConnectedStateRoute { - const { authority } = input - if (input.origin === 'initial-hydration') { - deps.rememberReconnectAuthority(null) - void deps.prepareHost(authority, 'initial-hydration') - return 'initial-hydration' - } - if (!directSshAuthoritiesEqual(input.previousAuthority, authority)) { - deps.rememberReconnectAuthority(authority) - if (deps.coordinatorRoutingEnabled) { - void deps.coordinator.requestReconnect(authority) - return 'changed-authority' - } - deps.coordinator.replaceAuthority(authority) - deps.invalidateStaleTerminalBindings(authority) - deps.retryTargetPanes(authority) - void deps.prepareHost(authority, 'reconnect', { authorityAlreadyReplaced: true }) - return 'changed-authority-fallback' - } - deps.coordinator.correctUnboundTerminals(authority, 'wake-refresh') - void deps.prepareHost(authority, 'wake-refresh') - return 'same-authority-wake' -} - export function directSshAuthorityFromConnectionState( targetId: string, state: SshConnectionState diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts deleted file mode 100644 index b7c75454220..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { - DirectSshWorktreeRefreshAttempt, - DirectSshWorktreeRefreshReleaseReason -} from './direct-ssh-worktree-refresh-scheduler-types' - -export function cancelDirectSshWorktreeRefreshAttempt( - attempt: DirectSshWorktreeRefreshAttempt, - reason: DirectSshWorktreeRefreshReleaseReason -): boolean { - try { - const outcome = attempt.cancel(reason) - return outcome !== false && outcome !== 'retained' && outcome !== 'already-settled' - } catch { - return true - } -} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts deleted file mode 100644 index 52064521dd7..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { - DirectSshWorktreeRefreshLogicalTask, - DirectSshWorktreeRefreshMetrics, - DirectSshWorktreeRefreshSchedulerSnapshot -} from './direct-ssh-worktree-refresh-scheduler-types' - -export function createDirectSshWorktreeRefreshMetrics(): DirectSshWorktreeRefreshMetrics { - return { - queueWaitDurationsMs: [], - providerExecutionDurationsMs: [], - timeoutRetryCount: 0, - locallySettledWaiterCount: 0, - cancelDebtCount: 0, - replacementAdmissionDelayedCount: 0, - overlappingJoinCount: 0, - peakLocallyUnsettled: 0, - estimatedLateWorkAllowanceCount: 0 - } -} - -export function adjustDirectSshAuthorityUnsettled( - unsettledByAuthority: Map, - authorityId: string, - delta: number -): void { - const next = (unsettledByAuthority.get(authorityId) ?? 0) + delta - if (next > 0) { - unsettledByAuthority.set(authorityId, next) - } else { - unsettledByAuthority.delete(authorityId) - } -} - -export function copyDirectSshWorktreeRefreshMetrics( - metrics: DirectSshWorktreeRefreshMetrics -): DirectSshWorktreeRefreshMetrics { - return { - ...metrics, - queueWaitDurationsMs: [...metrics.queueWaitDurationsMs], - providerExecutionDurationsMs: [...metrics.providerExecutionDurationsMs] - } -} - -export function recordDirectSshCancelDebt( - metrics: DirectSshWorktreeRefreshMetrics, - lateWorkLimit: number -): void { - metrics.cancelDebtCount++ - metrics.estimatedLateWorkAllowanceCount = Math.min( - lateWorkLimit, - metrics.estimatedLateWorkAllowanceCount + 1 - ) -} - -export function recordDirectSshQueueAdmission( - metrics: DirectSshWorktreeRefreshMetrics, - queueWaitDurationMs: number, - locallyUnsettled: number -): void { - metrics.queueWaitDurationsMs = [...metrics.queueWaitDurationsMs, queueWaitDurationMs] - metrics.peakLocallyUnsettled = Math.max(metrics.peakLocallyUnsettled, locallyUnsettled) -} - -export function recordDirectSshProviderExecution( - metrics: DirectSshWorktreeRefreshMetrics, - providerExecutionDurationMs: number -): void { - metrics.providerExecutionDurationsMs = [ - ...metrics.providerExecutionDurationsMs, - providerExecutionDurationMs - ] -} - -export function directSshWorktreeRefreshSchedulerSnapshot( - tasks: ReadonlyMap, - locallyUnsettled: number, - cancelDebtByAuthority: ReadonlyMap -): DirectSshWorktreeRefreshSchedulerSnapshot { - let queued = 0 - let retrying = 0 - let waiters = 0 - for (const task of tasks.values()) { - queued += task.state === 'queued' ? 1 : 0 - retrying += task.state === 'retrying' ? 1 : 0 - waiters += task.waiters.size - } - return { - locallyUnsettled, - queued, - retrying, - logicalTasks: tasks.size, - waiters, - cancelDebtByAuthority: new Map(cancelDebtByAuthority) - } -} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts deleted file mode 100644 index 1240304f8cd..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { - HostQualifiedDetectedWorktreeResult, - ProviderRequestId, - SshExecutionHostId -} from '../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { - DetectedWorktreeRefreshReleaseOutcome, - WaiterLeaseId -} from '../store/slices/detected-worktree-refresh-leases' - -export type { WaiterLeaseId } from '../store/slices/detected-worktree-refresh-leases' - -export type DirectSshWorktreeRefreshAuthorityRequirement = 'required' | 'allow-metadata-fallback' - -export type DirectSshWorktreeRefreshKey = DirectSshAuthority & { - repoId: string - executionHostId: SshExecutionHostId - catalogRevision: number - authorityRequirement: DirectSshWorktreeRefreshAuthorityRequirement -} - -export type DirectSshWorktreeRefreshTerminalStatus = - | 'complete' - | 'non-authoritative' - | 'timed-out' - | 'cancel-budget-exhausted' - | 'canceled' - | 'stale' - | 'rejected' - -export type DirectSshWorktreeRefreshOutcome = { - status: DirectSshWorktreeRefreshTerminalStatus - providerRequestId?: ProviderRequestId - providerResult?: HostQualifiedDetectedWorktreeResult - metrics?: DirectSshWorktreeRefreshMetrics -} - -export type DirectSshWorktreeRefreshMetrics = { - queueWaitDurationsMs: readonly number[] - providerExecutionDurationsMs: readonly number[] - timeoutRetryCount: number - locallySettledWaiterCount: number - cancelDebtCount: number - replacementAdmissionDelayedCount: number - overlappingJoinCount: number - peakLocallyUnsettled: number - estimatedLateWorkAllowanceCount: number -} - -export type DirectSshWorktreeRefreshReleaseReason = 'superseded' | 'invalidated' | 'stopped' - -export type DirectSshWorktreeRefreshLease = { - waiterLeaseId: WaiterLeaseId - result: Promise - release: (reason: DirectSshWorktreeRefreshReleaseReason) => void -} - -export type DirectSshWorktreeRefreshAttempt = { - providerRequestId: ProviderRequestId - result: Promise - cancel: ( - reason: DirectSshWorktreeRefreshReleaseReason - ) => boolean | void | DetectedWorktreeRefreshReleaseOutcome -} - -export type DirectSshWorktreeRefreshSchedulerDeps = { - startAttempt: (key: DirectSshWorktreeRefreshKey) => DirectSshWorktreeRefreshAttempt - createWaiterLeaseId?: () => WaiterLeaseId - onUnexpectedError?: (error: unknown) => void - now?: () => number -} - -export type DirectSshWorktreeRefreshSchedulerSnapshot = { - locallyUnsettled: number - queued: number - retrying: number - logicalTasks: number - waiters: number - cancelDebtByAuthority: ReadonlyMap -} - -export type DirectSshWorktreeRefreshScheduler = { - request: (key: DirectSshWorktreeRefreshKey) => DirectSshWorktreeRefreshLease - invalidateAuthority: (authority: DirectSshAuthority) => void - invalidateTarget: (targetId: string) => void - disposeProvider: (authority: DirectSshAuthority) => void - getSnapshot: () => DirectSshWorktreeRefreshSchedulerSnapshot - stop: () => void -} - -export type DirectSshWorktreeRefreshWaiter = { - resolve: (outcome: DirectSshWorktreeRefreshOutcome) => void -} - -export type DirectSshWorktreeRefreshLogicalTask = { - key: DirectSshWorktreeRefreshKey - keyId: string - authorityId: string - state: 'queued' | 'running' | 'retrying' | 'terminal' - attemptCount: number - attempt: DirectSshWorktreeRefreshAttempt | null - attemptCanceled: boolean - attemptStartedAt: number | null - queuedAt: number - metrics: DirectSshWorktreeRefreshMetrics - waiters: Map -} - -export function directSshProviderAuthorityKey(authority: DirectSshAuthority): string { - return JSON.stringify([ - authority.targetId, - authority.providerEpoch, - authority.connectionGeneration - ]) -} - -export function directSshWorktreeRefreshKey(key: DirectSshWorktreeRefreshKey): string { - return JSON.stringify([ - key.targetId, - key.repoId, - key.executionHostId, - key.providerEpoch, - key.connectionGeneration, - key.catalogRevision, - key.authorityRequirement - ]) -} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts deleted file mode 100644 index 800938a1a67..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { - HostQualifiedDetectedWorktreeResult, - ProviderRequestId -} from '../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { - createDirectSshWorktreeRefreshScheduler, - DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY, - type DirectSshWorktreeRefreshAttempt, - type DirectSshWorktreeRefreshKey, - type DirectSshWorktreeRefreshReleaseReason -} from './direct-ssh-worktree-refresh-scheduler' - -type Deferred = { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} - -type ControlledAttempt = DirectSshWorktreeRefreshAttempt & { - key: DirectSshWorktreeRefreshKey - deferred: Deferred - cancel: ReturnType> -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, resolve, reject } -} - -function epoch(value: string): SshProviderEpoch { - return value as SshProviderEpoch -} - -function requestId(value: string): ProviderRequestId { - return value as ProviderRequestId -} - -function key( - targetId: string, - repoId: string, - overrides: Partial = {} -): DirectSshWorktreeRefreshKey { - return { - targetId, - repoId, - executionHostId: `ssh:${targetId}`, - providerEpoch: epoch(`epoch-${targetId}`), - connectionGeneration: 1, - catalogRevision: 1, - authorityRequirement: 'required', - ...overrides - } -} - -function terminalResult( - attempt: ControlledAttempt, - status: 'timed-out' | 'canceled' | 'stale' | 'rejected' -): HostQualifiedDetectedWorktreeResult { - return { - status, - providerRequestId: attempt.providerRequestId, - executionHostId: attempt.key.executionHostId - } -} - -function completeResult( - attempt: ControlledAttempt, - status: 'complete' | 'non-authoritative' = 'complete' -): HostQualifiedDetectedWorktreeResult { - return { - status, - providerRequestId: attempt.providerRequestId, - repoId: attempt.key.repoId, - authority: { - kind: 'direct-ssh', - executionHostId: attempt.key.executionHostId, - targetId: attempt.key.targetId, - providerEpoch: attempt.key.providerEpoch, - connectionGeneration: attempt.key.connectionGeneration - }, - result: { - repoId: attempt.key.repoId, - authoritative: status === 'complete', - source: status === 'complete' ? 'git' : 'metadata-fallback', - worktrees: [] - } - } -} - -function createHarness(cancelOutcome?: 'cancel-failed', now?: () => number) { - const attempts: ControlledAttempt[] = [] - const onUnexpectedError = vi.fn() - const startAttempt = vi.fn((attemptKey: DirectSshWorktreeRefreshKey) => { - const providerDeferred = deferred() - const waiterDeferred = deferred() - const providerRequestId = requestId(`provider-${attempts.length + 1}`) - const attempt: ControlledAttempt = { - key: attemptKey, - providerRequestId, - result: waiterDeferred.promise, - deferred: providerDeferred, - cancel: vi.fn((_reason: DirectSshWorktreeRefreshReleaseReason) => { - waiterDeferred.resolve({ - providerRequestId, - executionHostId: attemptKey.executionHostId, - status: 'canceled' - }) - return cancelOutcome - }) - } - void providerDeferred.promise.then(waiterDeferred.resolve, waiterDeferred.reject) - attempts.push(attempt) - return attempt - }) - const scheduler = createDirectSshWorktreeRefreshScheduler({ - startAttempt, - onUnexpectedError, - now - }) - return { scheduler, attempts, startAttempt, onUnexpectedError } -} - -async function flushAttempt(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} - -describe('createDirectSshWorktreeRefreshScheduler', () => { - it('admits an idle singleton immediately and cleans it up on completion', async () => { - const { scheduler, attempts, startAttempt } = createHarness() - const lease = scheduler.request(key('target-a', 'repo-a')) - - expect(startAttempt).toHaveBeenCalledTimes(1) - expect(scheduler.getSnapshot().locallyUnsettled).toBe(1) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await expect(lease.result).resolves.toMatchObject({ status: 'complete' }) - await flushAttempt() - expect(scheduler.getSnapshot()).toMatchObject({ - locallyUnsettled: 0, - logicalTasks: 0, - waiters: 0 - }) - }) - - it('never admits more than five locally unsettled attempts', async () => { - const { scheduler, attempts } = createHarness() - const leases = Array.from({ length: 6 }, (_, index) => - scheduler.request(key('target-a', `repo-${index}`)) - ) - - expect(attempts).toHaveLength(DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY) - expect(scheduler.getSnapshot().queued).toBe(1) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await leases[0].result - await flushAttempt() - expect(attempts).toHaveLength(6) - expect(scheduler.getSnapshot().locallyUnsettled).toBe(5) - - scheduler.stop() - }) - - it('round-robins target lanes after at most one earlier lane admission', async () => { - const { scheduler, attempts } = createHarness() - const leases = Array.from({ length: 7 }, (_, index) => - scheduler.request(key('target-a', `repo-a-${index}`)) - ) - const targetB = scheduler.request(key('target-b', 'repo-b')) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await leases[0].result - await flushAttempt() - expect(attempts[5].key.targetId).toBe('target-a') - - attempts[1].deferred.resolve(completeResult(attempts[1])) - await leases[1].result - await flushAttempt() - expect(attempts[6].key.targetId).toBe('target-b') - - scheduler.stop() - await expect(targetB.result).resolves.toMatchObject({ status: 'canceled' }) - }) - - it('joins only exact keys and separates authority, revision, and requirement', () => { - const { scheduler, attempts } = createHarness() - const base = key('target-a', 'repo') - const first = scheduler.request(base) - const joined = scheduler.request({ ...base }) - scheduler.request({ ...base, providerEpoch: epoch('epoch-next') }) - scheduler.request({ ...base, connectionGeneration: 2 }) - scheduler.request({ ...base, catalogRevision: 2 }) - scheduler.request({ ...base, authorityRequirement: 'allow-metadata-fallback' }) - - expect(first.waiterLeaseId).not.toBe(joined.waiterLeaseId) - expect(attempts).toHaveLength(5) - expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 5, waiters: 6 }) - scheduler.stop() - }) - - it('keeps the logical barrier pending through one timeout and uses a fresh request ID', async () => { - const { scheduler, attempts } = createHarness() - const lease = scheduler.request(key('target-a', 'repo')) - let settled = false - void lease.result.then(() => { - settled = true - }) - - attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) - await flushAttempt() - - expect(settled).toBe(false) - expect(attempts).toHaveLength(2) - expect(attempts[1].providerRequestId).not.toBe(attempts[0].providerRequestId) - - attempts[1].deferred.resolve(completeResult(attempts[1])) - await expect(lease.result).resolves.toMatchObject({ - status: 'complete', - providerRequestId: attempts[1].providerRequestId - }) - }) - - it('requeues a timed-out retry at its target lane tail', async () => { - const { scheduler, attempts } = createHarness() - const retrying = scheduler.request(key('target-a', 'retrying')) - const blockers = Array.from({ length: 4 }, (_, index) => - scheduler.request(key('target-a', `blocker-${index}`)) - ) - const targetB = scheduler.request(key('target-b', 'target-b')) - - attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) - await flushAttempt() - expect(attempts[5].key.targetId).toBe('target-b') - - attempts[1].deferred.resolve(completeResult(attempts[1])) - await blockers[0].result - await flushAttempt() - expect(attempts[6].key.repoId).toBe('retrying') - - attempts[6].deferred.resolve(completeResult(attempts[6])) - await expect(retrying.result).resolves.toMatchObject({ status: 'complete' }) - scheduler.stop() - await expect(targetB.result).resolves.toMatchObject({ status: 'canceled' }) - }) - - it('makes a second timeout terminal', async () => { - const { scheduler, attempts } = createHarness() - const lease = scheduler.request(key('target-a', 'repo')) - - attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) - await flushAttempt() - attempts[1].deferred.resolve(terminalResult(attempts[1], 'timed-out')) - - await expect(lease.result).resolves.toMatchObject({ - status: 'timed-out', - providerRequestId: attempts[1].providerRequestId - }) - }) - - it('invalidates queued and current work without reporting cancellation errors', async () => { - const { scheduler, attempts, onUnexpectedError } = createHarness() - const leases = Array.from({ length: 6 }, (_, index) => - scheduler.request(key('target-a', `repo-${index}`)) - ) - - scheduler.invalidateTarget('target-a') - await expect(Promise.all(leases.map((lease) => lease.result))).resolves.toEqual( - Array.from({ length: 6 }, () => expect.objectContaining({ status: 'stale' })) - ) - expect(attempts.map((attempt) => attempt.cancel.mock.calls.length)).toEqual([1, 1, 1, 1, 1]) - expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) - - for (const attempt of attempts) { - attempt.deferred.reject(new Error('expected provider cancellation')) - } - await flushAttempt() - expect(onUnexpectedError).not.toHaveBeenCalled() - expect(scheduler.getSnapshot().locallyUnsettled).toBe(0) - }) - - it('bounds five failed cancellations to two replacements while old calls remain pending', async () => { - const { scheduler, attempts } = createHarness('cancel-failed') - const authority: DirectSshAuthority = { - targetId: 'target-a', - providerEpoch: epoch('epoch-target-a'), - connectionGeneration: 1 - } - const initial = Array.from({ length: 5 }, (_, index) => - scheduler.request(key('target-a', `old-${index}`)) - ) - scheduler.invalidateAuthority(authority) - await Promise.all(initial.map((lease) => lease.result)) - await flushAttempt() - expect(scheduler.getSnapshot()).toMatchObject({ - locallyUnsettled: 0, - queued: 0, - logicalTasks: 0 - }) - - const replacements = Array.from({ length: 3 }, (_, index) => - scheduler.request(key('target-a', `replacement-${index}`)) - ) - expect(attempts).toHaveLength(7) - expect(scheduler.getSnapshot().locallyUnsettled).toBe(2) - await expect(replacements[2].result).resolves.toMatchObject({ - status: 'cancel-budget-exhausted' - }) - - attempts[5].deferred.resolve(completeResult(attempts[5])) - attempts[6].deferred.resolve(completeResult(attempts[6])) - await expect( - Promise.all(replacements.slice(0, 2).map((lease) => lease.result)) - ).resolves.toEqual( - Array.from({ length: 2 }, () => expect.objectContaining({ status: 'complete' })) - ) - await flushAttempt() - expect(scheduler.getSnapshot().locallyUnsettled).toBe(0) - - scheduler.disposeProvider(authority) - for (const attempt of attempts.slice(0, 5)) { - attempt.deferred.reject(new Error('late canceled provider')) - } - await flushAttempt() - expect([...scheduler.getSnapshot().cancelDebtByAuthority.values()]).toEqual([]) - - const afterDisposal = scheduler.request(key('target-a', 'after-disposal')) - expect(attempts).toHaveLength(8) - attempts[7].deferred.resolve(completeResult(attempts[7])) - await expect(afterDisposal.result).resolves.toMatchObject({ status: 'complete' }) - }) - - it('settles one released waiter without canceling a joined provider', async () => { - const { scheduler, attempts } = createHarness() - const refreshKey = key('target-a', 'repo') - const released = scheduler.request(refreshKey) - const retained = scheduler.request(refreshKey) - - released.release('superseded') - await expect(released.result).resolves.toMatchObject({ status: 'canceled' }) - expect(attempts[0].cancel).not.toHaveBeenCalled() - expect(scheduler.getSnapshot().waiters).toBe(1) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await expect(retained.result).resolves.toMatchObject({ status: 'complete' }) - released.release('stopped') - await flushAttempt() - expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) - }) - - it('does not charge cancel debt when a shared provider invocation stays active', async () => { - const { scheduler, attempts } = createHarness() - const lease = scheduler.request(key('target-a', 'repo')) - attempts[0].cancel.mockReturnValue(false) - - lease.release('superseded') - - await expect(lease.result).resolves.toMatchObject({ status: 'canceled' }) - expect(attempts[0].cancel).toHaveBeenCalledOnce() - expect([...scheduler.getSnapshot().cancelDebtByAuthority.values()]).toEqual([]) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await flushAttempt() - }) - - it('stops current and queued waiters and rejects later requests without starting', async () => { - const { scheduler, attempts, startAttempt } = createHarness() - const current = scheduler.request(key('target-a', 'current')) - scheduler.stop() - - await expect(current.result).resolves.toMatchObject({ status: 'canceled' }) - expect(attempts[0].cancel).toHaveBeenCalledOnce() - - const afterStop = scheduler.request(key('target-a', 'after-stop')) - await expect(afterStop.result).resolves.toMatchObject({ status: 'canceled' }) - expect(startAttempt).toHaveBeenCalledOnce() - expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) - - attempts[0].deferred.reject(new Error('stopped')) - await flushAttempt() - }) - - it('surfaces non-authoritative and rejected terminal outcomes', async () => { - const { scheduler, attempts } = createHarness() - const metadata = scheduler.request(key('target-a', 'metadata')) - const rejected = scheduler.request(key('target-a', 'rejected')) - - attempts[0].deferred.resolve(completeResult(attempts[0], 'non-authoritative')) - attempts[1].deferred.resolve(terminalResult(attempts[1], 'rejected')) - - await expect(metadata.result).resolves.toMatchObject({ status: 'non-authoritative' }) - await expect(rejected.result).resolves.toMatchObject({ status: 'rejected' }) - }) - - it('reports queue wait, provider execution, retry, joins, and scoped peak metrics', async () => { - let now = 0 - const { scheduler, attempts } = createHarness(undefined, () => now) - const leases = Array.from({ length: 6 }, (_, index) => - scheduler.request(key('target-a', `repo-${index}`)) - ) - scheduler.request(key('target-a', 'repo-5')) - - now = 25 - attempts[0].deferred.resolve(completeResult(attempts[0])) - await leases[0].result - await flushAttempt() - expect(attempts).toHaveLength(6) - - now = 100 - attempts[5].deferred.resolve(terminalResult(attempts[5], 'timed-out')) - await flushAttempt() - now = 140 - attempts[6].deferred.resolve(completeResult(attempts[6])) - - await expect(leases[5].result).resolves.toMatchObject({ - status: 'complete', - metrics: { - queueWaitDurationsMs: [25, 0], - providerExecutionDurationsMs: [75, 40], - timeoutRetryCount: 1, - cancelDebtCount: 1, - overlappingJoinCount: 1, - peakLocallyUnsettled: 5 - } - }) - scheduler.stop() - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts deleted file mode 100644 index 2e95404f1b2..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts +++ /dev/null @@ -1,316 +0,0 @@ -import type { - HostQualifiedDetectedWorktreeResult, - ProviderRequestId -} from '../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { - DirectSshWorktreeRefreshAttempt, - DirectSshWorktreeRefreshKey, - DirectSshWorktreeRefreshLease, - DirectSshWorktreeRefreshLogicalTask as LogicalTask, - DirectSshWorktreeRefreshOutcome, - DirectSshWorktreeRefreshReleaseReason, - DirectSshWorktreeRefreshScheduler, - DirectSshWorktreeRefreshSchedulerDeps, - WaiterLeaseId -} from './direct-ssh-worktree-refresh-scheduler-types' -import { cancelDirectSshWorktreeRefreshAttempt } from './direct-ssh-worktree-refresh-scheduler-cancellation' -import { - adjustDirectSshAuthorityUnsettled, - copyDirectSshWorktreeRefreshMetrics, - createDirectSshWorktreeRefreshMetrics, - directSshWorktreeRefreshSchedulerSnapshot, - recordDirectSshCancelDebt, - recordDirectSshProviderExecution, - recordDirectSshQueueAdmission -} from './direct-ssh-worktree-refresh-scheduler-metrics' -import { DirectSshWorktreeRefreshTargetQueue } from './direct-ssh-worktree-refresh-target-queue' -import { - directSshProviderAuthorityKey, - directSshWorktreeRefreshKey -} from './direct-ssh-worktree-refresh-scheduler-types' -export type * from './direct-ssh-worktree-refresh-scheduler-types' - -export const DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY = 5 -export const DIRECT_SSH_PROVIDER_START_BUDGET = 7 - -export function createDirectSshWorktreeRefreshScheduler( - deps: DirectSshWorktreeRefreshSchedulerDeps -): DirectSshWorktreeRefreshScheduler { - const now = deps.now ?? Date.now - const tasksByKey = new Map() - const targetQueue = new DirectSshWorktreeRefreshTargetQueue() - const unsettledByAuthority = new Map() - const cancelDebtByAuthority = new Map() - let locallyUnsettled = 0 - let leaseSequence = 0 - let stopped = false - - const createLeaseId = (): WaiterLeaseId => - deps.createWaiterLeaseId?.() ?? (`direct-ssh-waiter-${++leaseSequence}` as WaiterLeaseId) - - const metricsFor = ( - task: LogicalTask, - locallySettledWaiterCount = task.metrics.locallySettledWaiterCount - ): DirectSshWorktreeRefreshOutcome['metrics'] => { - const metrics = copyDirectSshWorktreeRefreshMetrics(task.metrics) - metrics.locallySettledWaiterCount = locallySettledWaiterCount - return metrics - } - - const finishTask = (task: LogicalTask, outcome: DirectSshWorktreeRefreshOutcome): void => { - if (task.state === 'terminal') { - return - } - task.state = 'terminal' - task.attempt = null - tasksByKey.delete(task.keyId) - task.metrics.locallySettledWaiterCount += task.waiters.size - const settledOutcome = { ...outcome, metrics: metricsFor(task) } - for (const waiter of task.waiters.values()) { - waiter.resolve(settledOutcome) - } - task.waiters.clear() - } - - const addCancelDebt = (task: LogicalTask): void => { - recordDirectSshCancelDebt( - task.metrics, - DIRECT_SSH_PROVIDER_START_BUDGET - DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY - ) - cancelDebtByAuthority.set( - task.authorityId, - (cancelDebtByAuthority.get(task.authorityId) ?? 0) + 1 - ) - } - - const cancelAttempt = ( - task: LogicalTask, - reason: DirectSshWorktreeRefreshReleaseReason - ): void => { - if (!task.attempt || task.attemptCanceled) { - return - } - task.attemptCanceled = true - if (cancelDirectSshWorktreeRefreshAttempt(task.attempt, reason)) { - addCancelDebt(task) - } - } - - const canStart = (task: LogicalTask): boolean => { - const unsettled = unsettledByAuthority.get(task.authorityId) ?? 0 - const debt = cancelDebtByAuthority.get(task.authorityId) ?? 0 - return unsettled + debt + 1 <= DIRECT_SSH_PROVIDER_START_BUDGET - } - - const handleAttemptResult = ( - task: LogicalTask, - requestId: ProviderRequestId, - result: HostQualifiedDetectedWorktreeResult - ): void => { - if (task.state === 'terminal' || task.attempt?.providerRequestId !== requestId) { - return - } - if (result.providerRequestId !== requestId) { - finishTask(task, { status: 'rejected', providerRequestId: requestId }) - return - } - if (result.status === 'timed-out') { - addCancelDebt(task) - if (task.attemptCount === 1) { - task.metrics.timeoutRetryCount++ - task.attempt = null - task.attemptCanceled = true - targetQueue.enqueue(task, true, now()) - return - } - } - const status = - result.status === 'authority-unknown' || result.status === 'ambiguous-owner' - ? 'non-authoritative' - : result.status - finishTask(task, { - status, - providerRequestId: result.providerRequestId, - providerResult: result - }) - } - - function drain(): void { - while (!stopped && locallyUnsettled < DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY) { - const task = targetQueue.takeNext() - if (!task) { - return - } - if (!canStart(task)) { - task.metrics.replacementAdmissionDelayedCount++ - finishTask(task, { status: 'cancel-budget-exhausted' }) - continue - } - task.state = 'running' - task.attemptCount++ - locallyUnsettled++ - recordDirectSshQueueAdmission( - task.metrics, - Math.max(0, now() - task.queuedAt), - locallyUnsettled - ) - adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, 1) - let attempt: DirectSshWorktreeRefreshAttempt - try { - attempt = deps.startAttempt(task.key) - task.attempt = attempt - task.attemptCanceled = false - task.attemptStartedAt = now() - } catch (error) { - locallyUnsettled-- - adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, -1) - deps.onUnexpectedError?.(error) - finishTask(task, { status: 'rejected' }) - continue - } - void attempt.result - .then((result) => { - if (task.attemptStartedAt !== null) { - recordDirectSshProviderExecution( - task.metrics, - Math.max(0, now() - task.attemptStartedAt) - ) - task.attemptStartedAt = null - } - handleAttemptResult(task, attempt.providerRequestId, result) - }) - .catch((error) => { - if (task.attemptStartedAt !== null) { - recordDirectSshProviderExecution( - task.metrics, - Math.max(0, now() - task.attemptStartedAt) - ) - task.attemptStartedAt = null - } - if (task.state !== 'terminal' && !task.attemptCanceled) { - deps.onUnexpectedError?.(error) - finishTask(task, { - status: 'rejected', - providerRequestId: attempt.providerRequestId - }) - } - }) - .finally(() => { - locallyUnsettled-- - adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, -1) - drain() - }) - } - } - - const releaseWaiter = ( - task: LogicalTask, - waiterLeaseId: WaiterLeaseId, - reason: DirectSshWorktreeRefreshReleaseReason - ): void => { - const waiter = task.waiters.get(waiterLeaseId) - if (!waiter) { - return - } - task.waiters.delete(waiterLeaseId) - task.metrics.locallySettledWaiterCount++ - waiter.resolve({ status: 'canceled', metrics: metricsFor(task) }) - if (task.waiters.size > 0) { - return - } - cancelAttempt(task, reason) - finishTask(task, { status: 'canceled' }) - } - - const request = (key: DirectSshWorktreeRefreshKey): DirectSshWorktreeRefreshLease => { - const keyId = directSshWorktreeRefreshKey(key) - let task = tasksByKey.get(keyId) - if (!task) { - const requestedAt = now() - task = { - key, - keyId, - authorityId: directSshProviderAuthorityKey(key), - state: 'queued', - attemptCount: 0, - attempt: null, - attemptCanceled: false, - attemptStartedAt: null, - queuedAt: requestedAt, - metrics: createDirectSshWorktreeRefreshMetrics(), - waiters: new Map() - } - tasksByKey.set(keyId, task) - targetQueue.enqueue(task, false, now()) - } else { - task.metrics.overlappingJoinCount++ - } - const waiterLeaseId = createLeaseId() - let resolve!: (outcome: DirectSshWorktreeRefreshOutcome) => void - const result = new Promise((settle) => { - resolve = settle - }) - task.waiters.set(waiterLeaseId, { resolve }) - if (stopped) { - releaseWaiter(task, waiterLeaseId, 'stopped') - targetQueue.clear() - } else { - drain() - } - return { - waiterLeaseId, - result, - release: (reason) => releaseWaiter(task!, waiterLeaseId, reason) - } - } - - const invalidateMatching = (predicate: (task: LogicalTask) => boolean): void => { - for (const task of tasksByKey.values()) { - if (!predicate(task)) { - continue - } - cancelAttempt(task, 'invalidated') - finishTask(task, { status: 'stale', providerRequestId: task.attempt?.providerRequestId }) - } - drain() - } - - const invalidateAuthority = (authority: DirectSshAuthority): void => { - const id = directSshProviderAuthorityKey(authority) - invalidateMatching((task) => task.authorityId === id) - } - - const invalidateTarget = (targetId: string): void => { - invalidateMatching((task) => task.key.targetId === targetId) - } - - const disposeProvider = (authority: DirectSshAuthority): void => { - invalidateAuthority(authority) - cancelDebtByAuthority.delete(directSshProviderAuthorityKey(authority)) - } - - const getSnapshot = () => - directSshWorktreeRefreshSchedulerSnapshot(tasksByKey, locallyUnsettled, cancelDebtByAuthority) - - const stop = (): void => { - if (stopped) { - return - } - stopped = true - for (const task of tasksByKey.values()) { - cancelAttempt(task, 'stopped') - finishTask(task, { status: 'canceled', providerRequestId: task.attempt?.providerRequestId }) - } - targetQueue.clear() - } - - return { - request, - invalidateAuthority, - invalidateTarget, - disposeProvider, - getSnapshot, - stop - } -} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts deleted file mode 100644 index 4d549b4b437..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { DirectSshWorktreeRefreshLogicalTask } from './direct-ssh-worktree-refresh-scheduler-types' - -export class DirectSshWorktreeRefreshTargetQueue { - private readonly queuedByTarget = new Map() - private readonly targetOrder: string[] = [] - - enqueue(task: DirectSshWorktreeRefreshLogicalTask, retrying: boolean, now: number): void { - task.state = retrying ? 'retrying' : 'queued' - task.queuedAt = now - const lane = this.queuedByTarget.get(task.key.targetId) - if (lane) { - lane.push(task) - return - } - this.queuedByTarget.set(task.key.targetId, [task]) - this.targetOrder.push(task.key.targetId) - } - - takeNext(): DirectSshWorktreeRefreshLogicalTask | null { - while (this.targetOrder.length > 0) { - const targetId = this.targetOrder.shift()! - const lane = this.queuedByTarget.get(targetId) - const task = lane?.shift() - if (!lane || !task) { - this.queuedByTarget.delete(targetId) - continue - } - if (lane.length > 0) { - this.targetOrder.push(targetId) - } else { - this.queuedByTarget.delete(targetId) - } - if (task.state !== 'terminal' && task.waiters.size > 0) { - return task - } - } - return null - } - - clear(): void { - this.queuedByTarget.clear() - this.targetOrder.length = 0 - } -} diff --git a/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts index bdbddb8ee0e..bb78b94aff2 100644 --- a/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts @@ -3,24 +3,14 @@ import { buildStoreState } from './ipc-events-agent-status-store-test-fixtures' import type { StoreLike } from './ipc-events-agent-status-store-test-fixtures' import { buildWindowApi } from './ipc-events-agent-status-window-test-fixtures' -export type DirectSshReconnectCoordinatorDouble = { - requestReconnect: ReturnType - replaceAuthority: ReturnType - prepareOnly: ReturnType - correctUnboundTerminals: ReturnType - invalidate: ReturnType - stop: ReturnType -} - -/** Store/coordinator doubles for the partial-authority reconciliation path; the spec wires them. */ +/** Store doubles for the partial-authority reconciliation path; the spec wires them. */ export function buildSshAuthorityReconciliationHarness(args: { partialAuthority: { providerEpoch?: string; connectionGeneration?: number } latestAuthority: { providerEpoch: string; connectionGeneration: number } }): { - coordinator: DirectSshReconnectCoordinatorDouble + clearBindings: ReturnType emitPartialState: () => void getState: ReturnType - requestReconnect: ReturnType setSshConnectionState: ReturnType storeState: StoreLike storedState: () => Record | undefined @@ -37,7 +27,7 @@ export function buildSshAuthorityReconciliationHarness(args: { const sshConnectionStates = new Map>() let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined const getState = vi.fn(() => Promise.resolve(latestState)) - const requestReconnect = vi.fn(async () => ({ status: 'complete' })) + const clearBindings = vi.fn() const setSshConnectionState = vi.fn((nextTargetId: string, state: Record) => { sshConnectionStates.set(nextTargetId, state) }) @@ -45,22 +35,12 @@ export function buildSshAuthorityReconciliationHarness(args: { sshTargetLabels: new Map([[targetId, 'Reconciliation Target']]), sshConnectionStates, setSshConnectionState, - invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0), setSshTargetsMetadata: vi.fn(), setRemovedSshTargetLabels: vi.fn(), clearRemoteDetectedAgents: vi.fn(), - clearDirectSshTargetPtyBindings: vi.fn(), + clearDirectSshTargetPtyBindings: clearBindings, clearRemovedSshTargetState: vi.fn() }) - const coordinator = { - requestReconnect, - replaceAuthority: vi.fn(), - prepareOnly: vi.fn(), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn(), - stop: vi.fn() - } - vi.stubGlobal( 'window', buildWindowApi({ @@ -76,7 +56,7 @@ export function buildSshAuthorityReconciliationHarness(args: { ) return { - coordinator, + clearBindings, emitPartialState: () => { if (!sshStateListener) { throw new Error('Expected SSH state listener') @@ -84,7 +64,6 @@ export function buildSshAuthorityReconciliationHarness(args: { sshStateListener({ targetId, state: partialState }) }, getState, - requestReconnect, setSshConnectionState, storeState, storedState: () => sshConnectionStates.get(targetId) diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 54f88778d43..747dcf8a653 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -11,7 +11,6 @@ import { registerBackgroundWorktreeRemovalBridge } from './background-worktree-r import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge' import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' import { registerContentCreationIpcBridge } from './content-creation-ipc-bridge' -import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge' @@ -57,7 +56,6 @@ export function installAppLifetimeIpcEvents( onCleanupPhase?: (phase: IpcEventsCleanupPhase) => void ): () => void { const unsubs: (() => void)[] = [] - const directSshRuntime = createDirectSshBridgeRuntime() const backgroundWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() unsubs.push(backgroundWakeDispatcher.dispose) unsubs.push(attachMobileMarkdownBridge()) @@ -128,7 +126,7 @@ export function installAppLifetimeIpcEvents( registerBrowserRequestIpcBridge(unsubs) registerTabLifecycleIpcBridge(unsubs) registerRateLimitIpcBridge(unsubs) - registerDirectSshStateIpcBridge(unsubs, directSshRuntime) + const stopDirectSshStateBridge = registerDirectSshStateIpcBridge(unsubs) registerZoomIpcBridge(unsubs) const agentStatusBridge = registerAgentStatusIpcBridge(unsubs) const disposeMobileDriverHydration = registerMobileDriverIpcBridge( @@ -147,7 +145,7 @@ export function installAppLifetimeIpcEvents( onCleanupPhase?.('agentStore.unsubscribe') unsubs.forEach((unsubscribe) => unsubscribe()) onCleanupPhase?.('ipc.dispose') - directSshRuntime.stop() + stopDirectSshStateBridge() onCleanupPhase?.('directSsh.stop') resetAgentHookCompletionNotificationCoordinators() onCleanupPhase?.('notifications.reset') diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts deleted file mode 100644 index 5d44880146e..00000000000 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry' -import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees' -import { toSshExecutionHostId } from '../../../../shared/execution-host' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' -import { useAppStore } from '../../store' -import { - createDirectSshHostHydration, - type DirectSshHostHydration -} from '../direct-ssh-host-hydration' -import { - createDirectSshReconnectCoordinator, - type DirectSshPreparationInput, - type DirectSshPreparationReason, - type DirectSshReconnectCoordinator -} from '../direct-ssh-reconnect-coordinator' -import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' -import { createDirectSshWorktreeRefreshScheduler } from '../direct-ssh-worktree-refresh-scheduler' -import type { AppState } from '../../store/types' - -type DirectSshTerminalActions = Partial> -type AuthorityDeadline = { timer: ReturnType; settle: () => void } - -export type DirectSshBridgeRuntime = { - reconnectAuthorityByTarget: Map - reconnectCoordinator: DirectSshReconnectCoordinator - hostHydration: DirectSshHostHydration - currentAuthority: (targetId: string) => DirectSshAuthority | null - terminalActions: () => DirectSshTerminalActions - prepareHost: ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason, - options?: { authorityAlreadyReplaced?: boolean } - ) => Promise - isStopped: () => boolean - addDeadline: (deadline: AuthorityDeadline) => void - removeDeadline: (deadline: AuthorityDeadline) => void - stop: () => void -} - -export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime { - const reconnectAuthorityByTarget = new Map() - const deadlines = new Set() - let stopped = false - const currentAuthority = (targetId: string): DirectSshAuthority | null => { - const state = useAppStore.getState().sshConnectionStates?.get(targetId) - if ( - state?.status !== 'connected' || - state.targetId !== targetId || - !state.providerEpoch || - state.connectionGeneration === undefined - ) { - return null - } - return { - targetId, - providerEpoch: state.providerEpoch, - connectionGeneration: state.connectionGeneration - } - } - const scheduler = createDirectSshWorktreeRefreshScheduler({ - startAttempt: (key) => { - const acquired = acquireDirectSshDetectedWorktreeRefresh(useAppStore, { - repoId: key.repoId, - executionHostId: key.executionHostId, - authority: { - targetId: key.targetId, - providerEpoch: key.providerEpoch, - connectionGeneration: key.connectionGeneration - }, - requireAuthoritative: key.authorityRequirement === 'required' - }) - return { - providerRequestId: acquired.providerRequestId, - result: acquired.result.then((result) => acquired.merge(result)), - cancel: acquired.release - } - } - }) - const hostHydration = createDirectSshHostHydration({ - store: useAppStore, - isCurrentAuthority: (authority) => - directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority), - listRepos: (authority) => { - const executionHostId = toSshExecutionHostId(authority.targetId) - return ( - window.api.repos.listForExecutionHost?.({ - executionHostId, - expectedAuthority: authority - }) ?? - Promise.resolve({ authoritative: false, executionHostId, reason: 'unavailable' as const }) - ) - }, - listLineage: (authority) => { - const executionHostId = toSshExecutionHostId(authority.targetId) - return ( - window.api.worktrees.listLineageForHost?.({ - executionHostId, - expectedAuthority: authority - }) ?? - Promise.resolve({ authoritative: false, executionHostId, reason: 'unavailable' as const }) - ) - } - }) - const terminalActions = (): DirectSshTerminalActions => - useAppStore.getState() as DirectSshTerminalActions - const reconnectCoordinator = createDirectSshReconnectCoordinator({ - scheduler, - isCurrentConnectedAuthority: (authority) => - directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority), - capturePreparationInput: hostHydration.capturePreparationInput, - readHostScopedLineage: hostHydration.readHostScopedLineage, - invalidateStaleTerminalBindings: (authority) => - terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, - // Why 0: a pane on an `ssh:` row never spawns, so there is no pane to retry. - retryTargetPanes: () => 0, - correctUnboundTerminalPanes: (authority) => { - terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) - return 0 - }, - onTelemetry: createDirectSshReconnectProductTelemetryAdapter() - }) - const prepareHost: DirectSshBridgeRuntime['prepareHost'] = async (authority, reason, options) => { - try { - if (!options?.authorityAlreadyReplaced) { - reconnectCoordinator.replaceAuthority(authority) - } - const input: DirectSshPreparationInput | null = await hostHydration.capturePreparationInput( - authority, - reason - ) - if (input) { - await reconnectCoordinator.prepareOnly(input) - } - } catch { - // A failed preparation leaves the previous host picture; the next connect or wake retries. - } - } - return { - reconnectAuthorityByTarget, - reconnectCoordinator, - hostHydration, - currentAuthority, - terminalActions, - prepareHost, - isStopped: () => stopped, - addDeadline: (deadline) => deadlines.add(deadline), - removeDeadline: (deadline) => deadlines.delete(deadline), - stop: () => { - stopped = true - for (const deadline of deadlines) { - clearTimeout(deadline.timer) - deadline.settle() - } - deadlines.clear() - hostHydration.stop() - reconnectCoordinator.stop() - reconnectAuthorityByTarget.clear() - } - } -} diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts index afcb4aa8248..6213422710d 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts @@ -5,7 +5,6 @@ import type { SshConnectionState, SshProviderEpoch } from '../../../../shared/ssh-types' -import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' function connectedState( @@ -81,27 +80,19 @@ async function createHarness( } }) const store = useAppStore.getState() - const invalidate = vi.spyOn(store, 'invalidateStaleDirectSshTargetPtyBindings').mockReturnValue(1) const clearBindings = vi.spyOn(store, 'clearDirectSshTargetPtyBindings').mockReturnValue(1) - const runtime = createDirectSshBridgeRuntime() - const requestReconnect = vi.spyOn(runtime.reconnectCoordinator, 'requestReconnect') - const prepareHost = vi.spyOn(runtime, 'prepareHost') const unsubs: (() => void)[] = [] + const stop = registerDirectSshStateIpcBridge(unsubs) cleanups.push(() => { for (const unsubscribe of unsubs) { unsubscribe() } - runtime.stop() + stop() }) - registerDirectSshStateIpcBridge(unsubs, runtime) await settle() return { - runtime, getState, - invalidate, clearBindings, - requestReconnect, - prepareHost, emit: (state: SshConnectionState) => { if (!stateListener) { throw new Error('SSH state listener was not registered') @@ -128,9 +119,9 @@ afterEach(async () => { vi.unstubAllGlobals() }) -describe('direct SSH connect reply routing', () => { +describe('direct SSH connected-state routing', () => { it.each(['reply-first', 'push-first'] as const)( - 'clears stale bindings through the real coordinator once when connection ordering is %s', + 'clears the target bindings once per connected push when ordering is %s', async (ordering) => { const harness = await createHarness() const state = connectedState() @@ -142,50 +133,33 @@ describe('direct SSH connect reply routing', () => { harness.applyConnectReply(state) } - expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.runtime.reconnectAuthorityByTarget.get(state.targetId)).toEqual( - authority(state) - ) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) await settle() - harness.invalidate.mockClear() + harness.clearBindings.mockClear() harness.emit({ ...state }) - - expect(harness.requestReconnect).toHaveBeenCalledOnce() - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.prepareHost).toHaveBeenLastCalledWith(authority(state), 'wake-refresh') + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) } ) - it('keeps hydration preparation-only and corrects its duplicate without requesting reconnect', async () => { + it('leaves bindings alone on initial hydration and clears on the next push', async () => { const state = connectedState() const harness = await createHarness([state]) - expect(harness.prepareHost).toHaveBeenCalledExactlyOnceWith( - authority(state), - 'initial-hydration' - ) - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).not.toHaveBeenCalled() + expect(harness.clearBindings).not.toHaveBeenCalled() harness.emit({ ...state }) - - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.runtime.reconnectAuthorityByTarget.size).toBe(0) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) const next = connectedState(state.targetId, 2) harness.applyConnectReply(next) - harness.invalidate.mockClear() + harness.clearBindings.mockClear() harness.emit(next) - - expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(next)) - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(next)) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(next.targetId) }) it.each(['disconnected', 'error'] as const)( - 'forgets a routed authority after %s even when the next connect reply reaches the store first', + 'clears bindings on %s and again on the next connected push', async (status) => { const state = connectedState() const harness = await createHarness([state]) @@ -194,17 +168,12 @@ describe('direct SSH connect reply routing', () => { expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) harness.applyConnectReply(state) harness.emit(state) - - expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.runtime.reconnectAuthorityByTarget.get(state.targetId)).toEqual( - authority(state) - ) + expect(harness.clearBindings).toHaveBeenCalledTimes(2) } ) it.each(['providerEpoch', 'connectionGeneration'] as const)( - 'preserves initial hydration routing when %s must be reconciled', + 'reconciles a partial %s on initial hydration without clearing', async (missingField) => { const state = connectedState() const partial: SshConnectionState = { ...state } @@ -212,18 +181,10 @@ describe('direct SSH connect reply routing', () => { const harness = await createHarness([partial], [state]) expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toEqual(state) - expect(harness.prepareHost).toHaveBeenCalledExactlyOnceWith( - authority(state), - 'initial-hydration' - ) - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).not.toHaveBeenCalled() + expect(harness.clearBindings).not.toHaveBeenCalled() harness.emit(state) - - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.runtime.reconnectAuthorityByTarget.size).toBe(0) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) } ) @@ -244,19 +205,18 @@ describe('direct SSH connect reply routing', () => { delete partial[missingField] harness.emit(partial) - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).not.toHaveBeenCalled() + expect(harness.clearBindings).not.toHaveBeenCalled() resolveState(state) await settle() - expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.invalidate).toHaveBeenCalledWith(authority(state)) - harness.emit(state) - expect(harness.requestReconnect).toHaveBeenCalledOnce() + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) + expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toMatchObject( + authority(state) + ) } ) - it('keeps routed authorities and disconnect cleanup separate for each target', async () => { + it('keeps disconnect cleanup separate for each target', async () => { const harness = await createHarness() const first = connectedState('target-a') const second = connectedState('target-b') @@ -265,21 +225,10 @@ describe('direct SSH connect reply routing', () => { harness.emit(state) } await settle() - expect(harness.requestReconnect.mock.calls).toEqual([[authority(first)], [authority(second)]]) + expect(harness.clearBindings.mock.calls).toEqual([[first.targetId], [second.targetId]]) + harness.clearBindings.mockClear() harness.emit({ ...first, status: 'disconnected' }) - harness.invalidate.mockClear() - harness.emit(second) - - expect(harness.requestReconnect).toHaveBeenCalledTimes(2) - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(second)) - expect(harness.runtime.reconnectAuthorityByTarget.get(second.targetId)).toEqual( - authority(second) - ) - expect(harness.runtime.reconnectAuthorityByTarget.has(first.targetId)).toBe(false) - - harness.applyConnectReply(first) - harness.emit(first) - expect(harness.requestReconnect).toHaveBeenNthCalledWith(3, authority(first)) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(first.targetId) }) }) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts index 41559141daf..73030d0fd97 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts @@ -1,33 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { SshConnectionState } from '../../../../shared/ssh-types' -import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' function connectingState(targetId: string): SshConnectionState { return { targetId, status: 'connecting', error: null, reconnectAttempt: 0 } } -function bridgeRuntime(): DirectSshBridgeRuntime { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the routing under test reads only these members; the rest of the runtime is never touched. - return { - reconnectAuthorityByTarget: new Map(), - reconnectCoordinator: { - requestReconnect: vi.fn(async () => ({ status: 'complete' })), - replaceAuthority: vi.fn(), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn() - }, - currentAuthority: () => null, - terminalActions: () => ({}), - prepareHost: vi.fn(async () => {}), - isStopped: () => false, - addDeadline: vi.fn(), - removeDeadline: vi.fn(), - stop: vi.fn() - } as unknown as DirectSshBridgeRuntime -} - async function settle(): Promise { for (let index = 0; index < 40; index += 1) { await Promise.resolve() @@ -85,7 +64,7 @@ describe('direct SSH hydration fanout', () => { } }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() const states = useAppStore.getState().sshConnectionStates diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts index d2031965bbf..4873d5481bc 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts @@ -6,7 +6,6 @@ import { } from '@/store/slices/runtime-environment-ssh' import type { SshConnectionState } from '../../../../shared/ssh-types' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' -import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' type SshApiStub = { listTargets: () => Promise<{ id: string; label: string }[]> @@ -43,26 +42,6 @@ function stubWindow(ssh: SshApiStub): void { }) } -function bridgeRuntime(): DirectSshBridgeRuntime { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the routing under test reads only these members; the rest of the runtime is never touched. - return { - reconnectAuthorityByTarget: new Map(), - reconnectCoordinator: { - requestReconnect: vi.fn(async () => ({ status: 'complete' })), - replaceAuthority: vi.fn(), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn() - }, - currentAuthority: () => null, - terminalActions: () => ({}), - prepareHost: vi.fn(async () => {}), - isStopped: () => false, - addDeadline: vi.fn(), - removeDeadline: vi.fn(), - stop: vi.fn() - } as unknown as DirectSshBridgeRuntime -} - /** Lets pending IPC promises settle without leaning on a wall-clock delay. */ async function settle(): Promise { for (let index = 0; index < 12; index += 1) { @@ -102,7 +81,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(null) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() resolveRemovedLabels({ 'ssh-ghost': 'old devbox' }) @@ -126,7 +105,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(connectingState('ssh-live')) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() expect({ @@ -153,7 +132,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(null) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() // Older paired hosts lack the RPC; the target list is the evidence that matters and must survive. @@ -177,7 +156,7 @@ describe('direct SSH hydration target metadata', () => { : Promise.resolve(connectingState(targetId)) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() const state = useAppStore.getState() @@ -195,7 +174,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(null) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() expect({ @@ -226,7 +205,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(null) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() // A target added during the tombstone round trip pushes state and re-queries the list. diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-initial-state-hydration.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-initial-state-hydration.ts index 2967a74a0ce..f7336d2a55e 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-initial-state-hydration.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-initial-state-hydration.ts @@ -1,6 +1,5 @@ import type { SshConnectionState } from '../../../../shared/ssh-types' import { useAppStore } from '../../store' -import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' /** Why: an unanswered ssh.getState burns the full 30s runtime-RPC timeout, so a serial walk * makes the last target wait (N-1) timeouts; a small pool keeps a wedged target from blocking @@ -8,13 +7,13 @@ import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' const HYDRATION_FANOUT = 4 export async function hydrateDirectSshInitialState( - runtime: DirectSshBridgeRuntime, + isStopped: () => boolean, watermarkByTargetId: ReadonlyMap, applySshConnectionStateChange: (targetId: string, state: SshConnectionState) => void ): Promise { try { const targets = await window.api.ssh.listTargets() - if (runtime.isStopped()) { + if (isStopped()) { return } // Why: the loaded list is the hydration evidence (#9911) — never gate it behind the @@ -23,7 +22,7 @@ export async function hydrateDirectSshInitialState( const hydrateRemovedLabels = async (): Promise => { try { const removedLabels = await window.api.ssh.listRemovedTargetLabels() - if (!runtime.isStopped()) { + if (!isStopped()) { useAppStore.getState().setRemovedSshTargetLabels(removedLabels) } } catch (error) { @@ -34,14 +33,14 @@ export async function hydrateDirectSshInitialState( void hydrateRemovedLabels() let nextIndex = 0 const drainTargets = async (): Promise => { - while (nextIndex < targets.length && !runtime.isStopped()) { + while (nextIndex < targets.length && !isStopped()) { const target = targets[nextIndex] nextIndex += 1 const hydrationWatermark = watermarkByTargetId.get(target.id) ?? 0 try { const state = await window.api.ssh.getState({ targetId: target.id }) if ( - !runtime.isStopped() && + !isStopped() && state && (watermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark ) { diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts index 81f2ff21a42..88908fbf106 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -1,30 +1,27 @@ import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' import type { DirectSshAuthority, SshConnectionState } from '../../../../shared/ssh-types' import { useAppStore } from '../../store' -import { isDirectSshReconnectCoordinatorRoutingEnabled } from '../direct-ssh-reconnect-rollout' -import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' +import { directSshAuthoritiesEqual } from '../../store/slices/worktrees/listing/direct-ssh-authority' import { + directSshAuthorityFromConnectionState, registerDirectSshWakeRouting, - routeDirectSshConnectedState, type DirectSshConnectedStateOrigin } from '../direct-ssh-state-routing' -import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { hydrateDirectSshInitialState } from './direct-ssh-initial-state-hydration' import { applySshManagedServerTransition } from './ssh-managed-server-state-effects' -export function registerDirectSshStateIpcBridge( - unsubs: (() => void)[], - runtime: DirectSshBridgeRuntime -): void { - const { - reconnectAuthorityByTarget, - reconnectCoordinator, - currentAuthority, - terminalActions, - prepareHost - } = runtime + +type AuthorityDeadline = { timer: ReturnType; settle: () => void } + +/** Returns the stop that settles pending authority reads; listeners are released through unsubs. */ +export function registerDirectSshStateIpcBridge(unsubs: (() => void)[]): () => void { + let stopped = false + const isStopped = (): boolean => stopped + const deadlines = new Set() + const currentAuthority = (targetId: string): DirectSshAuthority | null => { + const state = useAppStore.getState().sshConnectionStates?.get(targetId) + return state ? directSshAuthorityFromConnectionState(targetId, state) : null + } const sshStateWatermarkByTargetId = new Map() - // Connect replies can update the store before this bridge routes the matching push. - const routedAuthorityByTarget = new Map() const pendingPortHydrationByTargetId = new Map< string, { receivedForwardPush: boolean; receivedDetectedPush: boolean } @@ -36,7 +33,7 @@ export function registerDirectSshStateIpcBridge( } pendingPortHydrationByTargetId.set(targetId, pendingPortHydration) const isHydrationAuthorityCurrent = (): boolean => - !runtime.isStopped() && directSshAuthoritiesEqual(currentAuthority(targetId), authority) + !isStopped() && directSshAuthoritiesEqual(currentAuthority(targetId), authority) const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => { if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) { useAppStore.getState().setPortForwards(targetId, forwards) @@ -58,7 +55,7 @@ export function registerDirectSshStateIpcBridge( state: SshConnectionState, origin: DirectSshConnectedStateOrigin ) => void - void hydrateDirectSshInitialState(runtime, sshStateWatermarkByTargetId, (targetId, state) => + void hydrateDirectSshInitialState(isStopped, sshStateWatermarkByTargetId, (targetId, state) => applySshConnectionStateChange(targetId, state, 'initial-hydration') ) unsubs.push( @@ -98,17 +95,17 @@ export function registerDirectSshStateIpcBridge( origin: DirectSshConnectedStateOrigin, watermark: number ): void => { - let pendingDeadline: { timer: ReturnType; settle: () => void } | undefined + let pendingDeadline: AuthorityDeadline | undefined const deadline = new Promise((resolve) => { const settle = (): void => resolve(null) const timer = setTimeout(settle, 5_000) pendingDeadline = { timer, settle } - runtime.addDeadline(pendingDeadline) + deadlines.add(pendingDeadline) }) void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline]) .then((latest) => { if ( - runtime.isStopped() || + isStopped() || latest?.targetId !== targetId || !latest?.providerEpoch || latest.connectionGeneration === undefined || @@ -144,7 +141,7 @@ export function registerDirectSshStateIpcBridge( .finally(() => { if (pendingDeadline) { clearTimeout(pendingDeadline.timer) - runtime.removeDeadline(pendingDeadline) + deadlines.delete(pendingDeadline) } }) } @@ -160,9 +157,6 @@ export function registerDirectSshStateIpcBridge( applySshManagedServerTransition(targetId, previousManagedServer, state.managedServer) if (canConnectSshStatus(state.status)) { - routedAuthorityByTarget.delete(targetId) - reconnectAuthorityByTarget.delete(targetId) - reconnectCoordinator.invalidate(targetId) store.clearRemoteDetectedAgents(targetId) store.clearPortForwards(targetId) @@ -180,26 +174,10 @@ export function registerDirectSshStateIpcBridge( reconcileSshAuthority(targetId, state, origin, sshStateWatermarkByTargetId.get(targetId) ?? 0) return } - const previousAuthority = routedAuthorityByTarget.get(targetId) ?? null - routedAuthorityByTarget.set(targetId, authority) - routeDirectSshConnectedState( - { - coordinator: reconnectCoordinator, - coordinatorRoutingEnabled: isDirectSshReconnectCoordinatorRoutingEnabled(), - invalidateStaleTerminalBindings: (nextAuthority) => - terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? 0, - retryTargetPanes: () => 0, - prepareHost, - rememberReconnectAuthority: (nextAuthority) => { - if (nextAuthority) { - reconnectAuthorityByTarget.set(targetId, nextAuthority) - } else { - reconnectAuthorityByTarget.delete(targetId) - } - } - }, - { authority, previousAuthority, origin } - ) + // Why: no pane binds an `ssh:` PTY, so every binding a newly pushed connection finds is stale. + if (origin === 'push') { + store.clearDirectSshTargetPtyBindings(targetId) + } if (origin === 'initial-hydration') { hydrateSshPorts(targetId, authority) } @@ -226,12 +204,11 @@ export function registerDirectSshStateIpcBridge( return } latestSshTargetStateEventByTargetId.delete(data.targetId) - if (runtime.isStopped()) { + if (isStopped()) { return } const latestStore = useAppStore.getState() if (!targets.some((target) => target.id === data.targetId)) { - routedAuthorityByTarget.delete(data.targetId) latestStore.clearRemovedSshTargetState(data.targetId) return } @@ -240,7 +217,7 @@ export function registerDirectSshStateIpcBridge( }) .catch(() => { if ( - !runtime.isStopped() && + !isStopped() && latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId ) { latestSshTargetStateEventByTargetId.delete(data.targetId) @@ -259,12 +236,19 @@ export function registerDirectSshStateIpcBridge( registerDirectSshWakeRouting({ getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], wakeAuthority: (authority) => { - reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh') - void prepareHost(authority, 'wake-refresh') + useAppStore.getState().clearDirectSshTargetPtyBindings(authority.targetId) }, ...(typeof window.api.ui.onSystemResumed === 'function' ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } : {}) }) ) + return () => { + stopped = true + for (const deadline of deadlines) { + clearTimeout(deadline.timer) + deadline.settle() + } + deadlines.clear() + } } diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts index 83e6d3781e0..e8a8d7c3054 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts @@ -7,14 +7,8 @@ import { stubAuxiliaryModules } from './ipc-events-agent-status-window-test-fixtures' import { buildSshAuthorityReconciliationHarness } from './ipc-events-ssh-authority-test-fixtures' -import type { DirectSshReconnectCoordinatorDouble } from './ipc-events-ssh-authority-test-fixtures' -function stubDirectSshModules(args: { - storeState: StoreLike - coordinator: DirectSshReconnectCoordinatorDouble - coordinatorRoutingEnabled?: boolean - capturePreparationInput?: ReturnType -}): void { +function stubDirectSshModules(args: { storeState: StoreLike }): void { stubReactSyncEffect() stubAuxiliaryModules() vi.doMock('../store', () => ({ @@ -23,29 +17,6 @@ function stubDirectSshModules(args: { getState: () => args.storeState } })) - vi.doMock('./direct-ssh-reconnect-rollout', () => ({ - isDirectSshReconnectCoordinatorRoutingEnabled: () => args.coordinatorRoutingEnabled ?? true - })) - vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({ - createDirectSshWorktreeRefreshScheduler: () => ({ - stop: vi.fn(), - disposeProvider: vi.fn() - }) - })) - vi.doMock('./direct-ssh-host-hydration', () => ({ - createDirectSshHostHydration: () => ({ - capturePreparationInput: args.capturePreparationInput ?? vi.fn(), - readHostScopedLineage: vi.fn(), - isPreparationTokenCurrent: vi.fn(() => true), - stop: vi.fn() - }) - })) - vi.doMock('./direct-ssh-reconnect-coordinator', () => ({ - createDirectSshReconnectCoordinator: () => args.coordinator - })) - vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({ - createDirectSshReconnectProductTelemetryAdapter: vi.fn() - })) } // Why: end-to-end exercise of startup agent-status restoration through @@ -103,7 +74,7 @@ describe('useIpcEvents agent status snapshot integration', () => { { partialAuthority: { providerEpoch: 'epoch-current' } }, { partialAuthority: { connectionGeneration: 7 } } ])( - 'fills a same-watermark partial SSH authority and routes it once', + 'fills a same-watermark partial SSH authority and clears its bindings once', async ({ partialAuthority }) => { const harness = buildSshAuthorityReconciliationHarness({ partialAuthority, @@ -112,14 +83,14 @@ describe('useIpcEvents agent status snapshot integration', () => { connectionGeneration: 7 } }) - stubDirectSshModules({ storeState: harness.storeState, coordinator: harness.coordinator }) + stubDirectSshModules({ storeState: harness.storeState }) const { useIpcEvents } = await import('./useIpcEvents') useIpcEvents() harness.emitPartialState() await vi.waitFor(() => { - expect(harness.requestReconnect).toHaveBeenCalledOnce() + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith('target-reconciliation') }) expect(harness.getState).toHaveBeenCalledOnce() expect(harness.setSshConnectionState).toHaveBeenCalledTimes(2) @@ -147,7 +118,7 @@ describe('useIpcEvents agent status snapshot integration', () => { connectionGeneration: 7 } }) - stubDirectSshModules({ storeState: harness.storeState, coordinator: harness.coordinator }) + stubDirectSshModules({ storeState: harness.storeState }) const { useIpcEvents } = await import('./useIpcEvents') useIpcEvents() @@ -157,7 +128,7 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(harness.getState).toHaveBeenCalledOnce() }) await Promise.resolve() - expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.clearBindings).not.toHaveBeenCalled() expect(harness.setSshConnectionState).toHaveBeenCalledOnce() expect(harness.storedState()).toEqual( expect.objectContaining({ @@ -168,110 +139,6 @@ describe('useIpcEvents agent status snapshot integration', () => { } ) - it.each([ - { enabled: true, expectedRoute: ['request'] }, - { - enabled: false, - expectedRoute: ['replace', 'invalidate', 'capture', 'prepare'] - } - ])( - 'routes a changed direct SSH authority through the enabled=$enabled path', - async ({ enabled, expectedRoute }) => { - const order: string[] = [] - let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined - const oldState = { - targetId: 'target-a', - status: 'connected' as const, - error: null, - reconnectAttempt: 0, - providerEpoch: 'epoch-old', - connectionGeneration: 1 - } - const nextState = { - ...oldState, - providerEpoch: 'epoch-new', - connectionGeneration: 2 - } - const storeState = buildStoreState({ - sshTargetLabels: new Map([['target-a', 'Target A']]), - sshConnectionStates: new Map([['target-a', oldState]]), - setSshConnectionState: (targetId: string, state: unknown) => { - ;(storeState.sshConnectionStates as Map).set(targetId, state) - }, - invalidateStaleDirectSshTargetPtyBindings: () => { - order.push('invalidate') - return 1 - }, - setSshTargetsMetadata: vi.fn(), - setRemovedSshTargetLabels: vi.fn(), - clearRemoteDetectedAgents: vi.fn(), - clearDirectSshTargetPtyBindings: vi.fn(), - clearRemovedSshTargetState: vi.fn() - }) - const coordinator = { - requestReconnect: vi.fn(async () => { - order.push('request') - return { status: 'complete' } - }), - replaceAuthority: vi.fn(() => { - order.push('replace') - }), - prepareOnly: vi.fn(async () => { - order.push('prepare') - return { token: null } - }), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn(), - stop: vi.fn() - } - const capturePreparationInput = vi.fn(async (authority, reason) => { - order.push('capture') - return { - ...authority, - reason, - catalogRevision: 1, - repoRefs: [], - authorityRequirement: 'required' - } - }) - - stubDirectSshModules({ - storeState, - coordinator, - coordinatorRoutingEnabled: enabled, - capturePreparationInput - }) - vi.stubGlobal( - 'window', - buildWindowApi({ - onSet: () => () => {}, - ssh: { - onStateChanged: (listener: (data: { targetId: string; state: unknown }) => void) => { - sshStateListener = listener - return () => {} - } - } - }) - ) - - const { useIpcEvents } = await import('./useIpcEvents') - useIpcEvents() - sshStateListener?.({ targetId: 'target-a', state: nextState }) - await Promise.resolve() - await Promise.resolve() - - expect(order).toEqual(expectedRoute) - if (enabled) { - expect(coordinator.requestReconnect).toHaveBeenCalledOnce() - expect(coordinator.prepareOnly).not.toHaveBeenCalled() - } else { - expect(coordinator.requestReconnect).not.toHaveBeenCalled() - expect(coordinator.replaceAuthority).toHaveBeenCalledOnce() - expect(coordinator.prepareOnly).toHaveBeenCalledOnce() - } - } - ) - it('does not let initial SSH port snapshots overwrite newer push events', async () => { const targetId = 'target-ports' const secondTargetId = 'target-ports-second' @@ -425,20 +292,11 @@ describe('useIpcEvents agent status snapshot integration', () => { fetchWorktreeLineage: vi.fn(async () => undefined), clearRemoteDetectedAgents: vi.fn(), clearDirectSshTargetPtyBindings: vi.fn(), - clearRemovedSshTargetState: vi.fn(), - invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0) + clearRemovedSshTargetState: vi.fn() }) - const coordinator = { - requestReconnect: vi.fn(async () => ({ status: 'complete' })), - replaceAuthority: vi.fn(), - prepareOnly: vi.fn(async () => ({ token: null })), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn(), - stop: vi.fn() - } let partialTargetStateCalls = 0 - stubDirectSshModules({ storeState, coordinator }) + stubDirectSshModules({ storeState }) vi.stubGlobal( 'window', buildWindowApi({ diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts deleted file mode 100644 index 49dcf0e80cb..00000000000 --- a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { EventProps } from '../../../shared/telemetry-events' - -export type DirectSshReconnectProductProps = EventProps<'direct_ssh_reconnect_operation'> - -export type DirectSshReconnectProductSink = ( - name: 'direct_ssh_reconnect_operation', - props: DirectSshReconnectProductProps -) => void diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts deleted file mode 100644 index 6df19c7a78c..00000000000 --- a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { DirectSshCoordinatorTelemetry } from '../hooks/direct-ssh-reconnect-coordinator' -import { - createDirectSshReconnectProductTelemetryAdapter, - toDirectSshReconnectProductProps -} from './direct-ssh-reconnect-product-telemetry' - -function coordinatorEvent( - overrides: Partial = {} -): DirectSshCoordinatorTelemetry { - return { - mode: 'prepare-only', - reason: 'workspace-snapshot', - outcome: 'degraded', - durationMs: 900, - staleBindingsCleared: 1, - retriedTerminals: 2, - correctedTerminals: 3, - terminalFinalizationDurationMs: 4, - catalogOutcome: 'complete', - catalogDurationMs: 20, - gitWorktreeCount: 5, - folderWorkspaceCount: 2, - ambiguousOwnerCount: 1, - contradictoryOwnerCount: 1, - repoOutcomes: { - complete: 2, - 'non-authoritative': 1, - 'timed-out': 1, - 'cancel-budget-exhausted': 1, - canceled: 1, - stale: 1, - rejected: 1 - }, - lineageOutcome: 'degraded', - queueWaitDurationsMs: [30, 10, 20, 40], - providerExecutionDurationsMs: [400, 100, 300, 200], - timeoutRetryCount: 2, - locallySettledWaiterCount: 4, - cancelDebtCount: 2, - replacementAdmissionDelayedCount: 1, - overlappingJoinCount: 3, - peakLocallyUnsettled: 4, - estimatedLateWorkAllowanceCount: 2, - authorityRotationCount: 1, - damped: true, - ...overrides - } -} - -describe('direct SSH reconnect product telemetry', () => { - it('maps one coordinator aggregate to privacy-safe product fields', () => { - const props = toDirectSshReconnectProductProps(coordinatorEvent()) - - expect(props).toMatchObject({ - mode: 'prepare_only', - reason: 'workspace_snapshot', - outcome: 'degraded', - repo_retrying_count: 2, - repo_timed_out_count: 1, - repo_rejected_count: 1, - repo_canceled_count: 1, - repo_stale_count: 1, - lineage_degraded_count: 1, - queue_wait_sample_count: 4, - queue_wait_duration_ms_p50: 20, - queue_wait_duration_ms_p95: 40, - provider_execution_sample_count: 4, - provider_execution_duration_ms_p50: 200, - provider_execution_duration_ms_p99: 400, - cancel_debt_count: 2, - replacement_admission_delayed_count: 1, - overlapping_join_count: 3, - timeout_retry_count: 2, - damped_preparation_count: 1 - }) - expect(Object.keys(props).join(' ')).not.toMatch( - /target_id|repo_id|host|path|label|user|request_id|lease_id|terminal_id|error/ - ) - expect(props).not.toHaveProperty('terminal_correction_failed_count') - expect(props).not.toHaveProperty('terminal_correction_rearmed_count') - expect(props).not.toHaveProperty('concurrent_non_coordinator_call_count') - expect(props).not.toHaveProperty('arrival_order_reply_discarded_count') - }) - - it('emits exactly one typed event per adapter call', () => { - const sink = vi.fn() - const emit = createDirectSshReconnectProductTelemetryAdapter(sink) - - emit(coordinatorEvent()) - - expect(sink).toHaveBeenCalledOnce() - expect(sink).toHaveBeenCalledWith( - 'direct_ssh_reconnect_operation', - expect.objectContaining({ total_duration_ms: 900 }) - ) - }) - - it('does not let telemetry failures escape into recovery', () => { - const emit = createDirectSshReconnectProductTelemetryAdapter(() => { - throw new Error('telemetry unavailable') - }) - - expect(() => emit(coordinatorEvent())).not.toThrow() - }) -}) diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts deleted file mode 100644 index bd798ad4e06..00000000000 --- a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { DirectSshCoordinatorTelemetry } from '../hooks/direct-ssh-reconnect-coordinator' -import { track } from './telemetry' -import type { - DirectSshReconnectProductProps, - DirectSshReconnectProductSink -} from './direct-ssh-reconnect-product-telemetry-types' - -const MAX_COUNT = 1_000_000 -const MAX_DURATION_MS = 86_400_000 - -function boundedInteger(value: number, maximum = MAX_COUNT): number { - return Math.min(maximum, Math.max(0, Math.round(Number.isFinite(value) ? value : 0))) -} - -function durationDistribution(values: readonly number[]): { - count: number - p50: number - p95: number - p99: number - max: number -} { - const sorted = values - .map((value) => boundedInteger(value, MAX_DURATION_MS)) - .toSorted((a, b) => a - b) - const percentile = (quantile: number): number => - sorted.length === 0 ? 0 : sorted[Math.max(0, Math.ceil(sorted.length * quantile) - 1)] - return { - count: boundedInteger(sorted.length), - p50: percentile(0.5), - p95: percentile(0.95), - p99: percentile(0.99), - max: sorted.at(-1) ?? 0 - } -} - -function lineageCounts( - outcome: DirectSshCoordinatorTelemetry['lineageOutcome'] -): Pick< - DirectSshReconnectProductProps, - | 'lineage_complete_count' - | 'lineage_degraded_count' - | 'lineage_canceled_count' - | 'lineage_stale_count' - | 'lineage_not_started_count' -> { - return { - lineage_complete_count: outcome === 'complete' ? 1 : 0, - lineage_degraded_count: outcome === 'degraded' ? 1 : 0, - lineage_canceled_count: outcome === 'canceled' ? 1 : 0, - lineage_stale_count: outcome === 'stale' ? 1 : 0, - lineage_not_started_count: outcome === 'not-started' ? 1 : 0 - } -} - -export function toDirectSshReconnectProductProps( - event: DirectSshCoordinatorTelemetry -): DirectSshReconnectProductProps { - const queueWait = durationDistribution(event.queueWaitDurationsMs) - const providerExecution = durationDistribution(event.providerExecutionDurationsMs) - return { - mode: event.mode === 'prepare-only' ? 'prepare_only' : 'reconnect', - reason: event.reason.replaceAll('-', '_') as DirectSshReconnectProductProps['reason'], - outcome: event.outcome, - terminal_retried_count: boundedInteger(event.retriedTerminals), - terminal_stale_binding_cleared_count: boundedInteger(event.staleBindingsCleared), - terminal_correction_succeeded_count: boundedInteger(event.correctedTerminals), - catalog_complete_count: event.catalogOutcome === 'complete' ? 1 : 0, - catalog_degraded_count: event.catalogOutcome === 'degraded' ? 1 : 0, - catalog_stale_count: event.catalogOutcome === 'stale' ? 1 : 0, - repo_complete_count: boundedInteger(event.repoOutcomes.complete), - repo_non_authoritative_count: boundedInteger(event.repoOutcomes['non-authoritative']), - // One first-timeout transition per repo makes retrying repos equal timeout retries. - repo_retrying_count: boundedInteger(event.timeoutRetryCount), - repo_timed_out_count: boundedInteger(event.repoOutcomes['timed-out']), - repo_cancel_budget_exhausted_count: boundedInteger( - event.repoOutcomes['cancel-budget-exhausted'] - ), - repo_canceled_count: boundedInteger(event.repoOutcomes.canceled), - repo_stale_count: boundedInteger(event.repoOutcomes.stale), - repo_rejected_count: boundedInteger(event.repoOutcomes.rejected), - ...lineageCounts(event.lineageOutcome), - git_worktree_count: boundedInteger(event.gitWorktreeCount), - folder_workspace_count: boundedInteger(event.folderWorkspaceCount), - ambiguous_owner_count: boundedInteger(event.ambiguousOwnerCount), - contradictory_owner_count: boundedInteger(event.contradictoryOwnerCount), - total_duration_ms: boundedInteger(event.durationMs, MAX_DURATION_MS), - terminal_finalization_duration_ms: boundedInteger( - event.terminalFinalizationDurationMs, - MAX_DURATION_MS - ), - catalog_duration_ms: boundedInteger(event.catalogDurationMs, MAX_DURATION_MS), - queue_wait_sample_count: queueWait.count, - queue_wait_duration_ms_p50: queueWait.p50, - queue_wait_duration_ms_p95: queueWait.p95, - queue_wait_duration_ms_p99: queueWait.p99, - queue_wait_duration_ms_max: queueWait.max, - provider_execution_sample_count: providerExecution.count, - provider_execution_duration_ms_p50: providerExecution.p50, - provider_execution_duration_ms_p95: providerExecution.p95, - provider_execution_duration_ms_p99: providerExecution.p99, - provider_execution_duration_ms_max: providerExecution.max, - timeout_retry_count: boundedInteger(event.timeoutRetryCount), - locally_settled_waiter_count: boundedInteger(event.locallySettledWaiterCount), - cancel_debt_count: boundedInteger(event.cancelDebtCount), - replacement_admission_delayed_count: boundedInteger(event.replacementAdmissionDelayedCount), - overlapping_join_count: boundedInteger(event.overlappingJoinCount), - coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: boundedInteger( - event.peakLocallyUnsettled, - 5 - ), - estimated_late_work_allowance_count: boundedInteger(event.estimatedLateWorkAllowanceCount, 2), - authority_rotation_count: boundedInteger(event.authorityRotationCount), - damped_preparation_count: event.damped ? 1 : 0 - } -} - -export function createDirectSshReconnectProductTelemetryAdapter( - sink: DirectSshReconnectProductSink = track -): (event: DirectSshCoordinatorTelemetry) => void { - return (event) => { - try { - sink('direct_ssh_reconnect_operation', toDirectSshReconnectProductProps(event)) - } catch { - // Recovery cannot depend on product telemetry. - } - } -} diff --git a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts index c0f05124eda..44a71a64730 100644 --- a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts +++ b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts @@ -5,7 +5,6 @@ import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test import { computeVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { createStoreSessionMockApi } from './store-session-test-harness' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -524,128 +523,4 @@ describe('reconnectPersistedTerminals', () => { expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) expect(after.workspaceSessionReady).toBe(false) }) - - it('leaves scoped reconnect state untouched when direct SSH authority is stale at entry', async () => { - const store = createDaemonEnabledStore() - const targetId = 'ssh-1' - const worktreeId = 'repo1::/remote/wt1' - const staleAuthority: DirectSshAuthority = { - targetId, - providerEpoch: 'epoch-1' as SshProviderEpoch, - connectionGeneration: 1 - } - store.setState({ - tabsByWorktree: { - [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] - }, - ptyIdsByTabId: { tab1: [] }, - pendingReconnectWorktreeIds: [worktreeId], - pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, - pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' }, - deferredSshSessionIdsByTabId: { tab1: 'ssh:ssh-1@@deferred-1' }, - sshConnectionStates: new Map([ - [ - targetId, - { - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: staleAuthority.providerEpoch, - connectionGeneration: 2 - } - ] - ]), - workspaceSessionReady: false - }) - const before = store.getState() - - await store.getState().reconnectPersistedTerminals(undefined, { - directSshAuthority: staleAuthority, - workspaceKeys: [worktreeId] - }) - - const after = store.getState() - expect(after.tabsByWorktree).toBe(before.tabsByWorktree) - expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) - expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) - expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) - expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) - expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) - expect(after.workspaceSessionReady).toBe(before.workspaceSessionReady) - }) - - it('leaves scoped reconnect state untouched when direct SSH authority rotates before publication', async () => { - const store = createDaemonEnabledStore() - const targetId = 'ssh-1' - const worktreeId = 'repo1::/remote/wt1' - const authority: DirectSshAuthority = { - targetId, - providerEpoch: 'epoch-1' as SshProviderEpoch, - connectionGeneration: 1 - } - let authorityReads = 0 - const currentAuthorityState = { - targetId, - status: 'connected' as const, - error: null, - reconnectAttempt: 0, - providerEpoch: authority.providerEpoch, - connectionGeneration: authority.connectionGeneration - } - const rotatedAuthorityState = { - ...currentAuthorityState, - connectionGeneration: 2 - } - const sshConnectionStates = new Map([[targetId, currentAuthorityState]]) - const originalGet = sshConnectionStates.get.bind(sshConnectionStates) - // Rotate from the first read after the entry authority check, so the - // pre-publication check sees the new generation whatever number of - // intermediate reads the reconnect pass happens to make. - sshConnectionStates.get = ((key: string) => { - authorityReads += 1 - return authorityReads >= 2 ? rotatedAuthorityState : originalGet(key) - }) as typeof sshConnectionStates.get - store.setState({ - repos: [ - { - id: 'repo1', - path: '/repo1', - displayName: 'Repo 1', - badgeColor: '#000', - addedAt: 0, - connectionId: targetId - } - ], - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/remote/wt1' })] - }, - tabsByWorktree: { - [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] - }, - ptyIdsByTabId: { tab1: [] }, - pendingReconnectWorktreeIds: [worktreeId], - pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, - pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' }, - deferredSshSessionIdsByTabId: {}, - sshConnectionStates, - workspaceSessionReady: false - }) - const before = store.getState() - - await store.getState().reconnectPersistedTerminals(undefined, { - directSshAuthority: authority, - workspaceKeys: [worktreeId] - }) - - const after = store.getState() - expect(authorityReads).toBeGreaterThanOrEqual(2) - expect(after.tabsByWorktree).toBe(before.tabsByWorktree) - expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) - expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) - expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) - expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) - expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) - expect(after.workspaceSessionReady).toBe(false) - }) }) diff --git a/src/renderer/src/store/slices/store-session-workspace-hydration.test.ts b/src/renderer/src/store/slices/store-session-workspace-hydration.test.ts index 3aca30fa942..88c43deedfb 100644 --- a/src/renderer/src/store/slices/store-session-workspace-hydration.test.ts +++ b/src/renderer/src/store/slices/store-session-workspace-hydration.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types' -import type { SshProviderEpoch } from '../../../../shared/ssh-types' import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { createTestStore, makeWorktree, makeTab, makeLayout, TEST_REPO } from './store-test-helpers' @@ -453,14 +452,8 @@ describe('restored folder workspace hydration', () => { const worktreeId = 'repo-a::/target' const retainedTab = makeTab({ id: 'tab-retained', worktreeId }) const retiredTab = makeTab({ id: 'tab-retired', worktreeId }) - const authority = { - targetId: 'target-a', - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 1 - } - store.setState({ - repos: [{ ...TEST_REPO, id: 'repo-a', connectionId: authority.targetId }], + repos: [{ ...TEST_REPO, id: 'repo-a', connectionId: 'target-a' }], worktreesByRepo: { 'repo-a': [makeWorktree({ id: worktreeId, repoId: 'repo-a', path: '/target' })] }, @@ -481,7 +474,7 @@ describe('restored folder workspace hydration', () => { tabsByWorktree: { [worktreeId]: [retainedTab] }, terminalLayoutsByTabId: {} }, - { directSshAuthority: authority, replaceWorkspaceKeys: [worktreeId] } + { replaceWorkspaceKeys: [worktreeId] } ) expect(store.getState().unverifiedPtyLossTabIds).toEqual({ diff --git a/src/renderer/src/store/slices/terminal-session-row-hydration.ts b/src/renderer/src/store/slices/terminal-session-row-hydration.ts index ea61dfe8ae5..ea0d6e5e8f9 100644 --- a/src/renderer/src/store/slices/terminal-session-row-hydration.ts +++ b/src/renderer/src/store/slices/terminal-session-row-hydration.ts @@ -21,14 +21,6 @@ type CanonicalTerminals = { aiVaultTitleByTabId: Map } -export type HydrateWorkspaceTerminalRowsOptions = { - /** - * Rows arrived from a remote snapshot. `unifiedTabs` is not on the remote wire, so the session's - * canonical list still describes the local client and cannot arbitrate ownership of these rows. - */ - rowsFromRemoteSnapshot?: boolean -} - export type WorkspaceTerminalRowHydration = { rows: TerminalTab[] /** PTYs a retained row must give up because a canonical row already owns them. */ @@ -50,8 +42,7 @@ export type WorkspaceTerminalRowHydration = { export function hydrateWorkspaceTerminalRows( session: WorkspaceSessionState, worktreeId: string, - rows: readonly TerminalTab[], - options: HydrateWorkspaceTerminalRowsOptions = {} + rows: readonly TerminalTab[] ): WorkspaceTerminalRowHydration { const canonical = readCanonicalTerminals(session, worktreeId, rows) const releasedPtyIdsByTabId = new Map>() @@ -66,9 +57,7 @@ export function hydrateWorkspaceTerminalRows( invalidTabIds.push(row.id) continue } - const claim = options.rowsFromRemoteSnapshot - ? RETAINED_UNCLAIMED - : resolveCanonicalPtyClaim(session, row, canonical) + const claim = resolveCanonicalPtyClaim(session, row, canonical) if (claim.kind === 'subsumed') { subsumedTabIds.push(row.id) canonicalTabIdBySubsumedTabId.set(row.id, claim.canonicalTabId) diff --git a/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts b/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts index 63daa20874f..f5a908633d6 100644 --- a/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts @@ -1,7 +1,6 @@ // Keep this bare import first: it installs the preload-API stub the store imports read at eval time. import './terminal-hydration-store-test-bootstrap' import { describe, expect, it, vi } from 'vitest' -import { hydrateWorkspaceTerminalRows } from './terminal-session-row-hydration' import { getOrphanTerminalIds } from './terminal-orphan-helpers' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { Tab } from '../../../../shared/tab-types' @@ -337,31 +336,6 @@ describe('hydrateWorkspaceSession canonical PTY overlap', () => { expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual([`split-tab:${soloLeafId}`]) }) - it('lets remote-snapshot rows keep every PTY, since unifiedTabs describes the local client', () => { - const sharedPtyId = 'daemon-shared' - const session = makeSession({ - tabs: [ - makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId }), - makeTab({ id: 'remote-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId, sortOrder: 1 }) - ], - layouts: { - 'canonical-tab': { ...makeLayout(), ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } }, - 'remote-tab': { ...makeLayout(), ptyIdsByLeafId: { 'remote-leaf': sharedPtyId } } - }, - canonicalEntityIds: ['canonical-tab'] - }) - const rows = session.tabsByWorktree[WORKTREE_ID]! - - expect( - hydrateWorkspaceTerminalRows(session, WORKTREE_ID, rows, { rowsFromRemoteSnapshot: true }) - .subsumedTabIds - ).toEqual([]) - // Guards the gate: without the flag the same rows collapse, so a caller that forgets it drops live remote rows. - expect(hydrateWorkspaceTerminalRows(session, WORKTREE_ID, rows).subsumedTabIds).toEqual([ - 'remote-tab' - ]) - }) - it('retains a non-canonical row that owns no PTY at all', () => { const sharedPtyId = 'daemon-shared' const session = makeSession({ diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index ed66a70a9cc..46b39f2f6df 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -92,7 +92,6 @@ const mockApi = { globalThis.window = { api: mockApi } import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import type { SshProviderEpoch } from '../../../../shared/ssh-types' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession @@ -711,127 +710,6 @@ describe('hydrateWorkspaceSession', () => { expect(store.getState().worktreeNavHistoryIndex).toBe(1) expect(canGoBackWorktreeHistory(store.getState())).toBe(true) }) - - it('hydrates and reconnects one SSH target without mutating unrelated state', async () => { - const store = createTestStore() - const targetWorktreeId = 'repo-a::/target' - const siblingWorktreeId = 'repo-b::/sibling' - const localWorktreeId = 'repo-local::/local' - const runtimeWorktreeId = 'repo-runtime::/runtime' - const folderWorktreeId = folderWorkspaceKey('folder-1') - const targetTab = makeTab({ id: 'tab-target', worktreeId: targetWorktreeId, ptyId: null }) - const deletedTargetTab = makeTab({ id: 'tab-target-deleted', worktreeId: targetWorktreeId }) - const siblingTab = makeTab({ - id: 'tab-sibling', - worktreeId: siblingWorktreeId, - ptyId: 'ssh:target-b@@pty-b' - }) - const runtimeTab = makeTab({ - id: 'tab-runtime', - worktreeId: runtimeWorktreeId, - ptyId: 'runtime:env@@pty-runtime' - }) - const localTab = makeTab({ id: 'tab-local', worktreeId: localWorktreeId, ptyId: null }) - const folderTab = makeTab({ id: 'tab-folder', worktreeId: folderWorktreeId, ptyId: null }) - const siblingTabs = [siblingTab] - const runtimeTabs = [runtimeTab] - const [localTabs, folderTabs] = [[localTab], [folderTab]] - const runtimeOwners = { [runtimeWorktreeId]: 'runtime:env' as const } - const authority = { - targetId: 'target-a', - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 7 - } - seedStore(store, { - workspaceSessionReady: true, - repos: [ - { ...TEST_REPO, id: 'repo-a', connectionId: 'target-a' }, - { ...TEST_REPO, id: 'repo-b', connectionId: 'target-b' } - ], - worktreesByRepo: { - 'repo-a': [makeWorktree({ id: targetWorktreeId, repoId: 'repo-a', path: '/target' })], - 'repo-b': [makeWorktree({ id: siblingWorktreeId, repoId: 'repo-b', path: '/sibling' })] - }, - tabsByWorktree: { - [targetWorktreeId]: [targetTab, deletedTargetTab], - [siblingWorktreeId]: siblingTabs, - [localWorktreeId]: localTabs, - [runtimeWorktreeId]: runtimeTabs, - [folderWorktreeId]: folderTabs - }, - ptyIdsByTabId: { - [targetTab.id]: [], - [siblingTab.id]: [siblingTab.ptyId!], - [runtimeTab.id]: [runtimeTab.ptyId!] - }, - activeRepoId: 'repo-b', - activeWorktreeId: siblingWorktreeId, - activeTabId: siblingTab.id, - restoredRuntimeHostIdByWorkspaceSessionKey: runtimeOwners, - sshConnectionStates: new Map([ - [ - authority.targetId, - { - targetId: authority.targetId, - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: authority.providerEpoch, - connectionGeneration: authority.connectionGeneration - } - ] - ]) - }) - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - activeRepoId: 'repo-a', - activeWorktreeId: targetWorktreeId, - activeTabId: targetTab.id, - tabsByWorktree: { - [targetWorktreeId]: [ - { ...targetTab, ptyId: 'ssh:target-a@@pty-a' }, - makeTab({ - id: 'tab-wrong-host', - worktreeId: targetWorktreeId, - ptyId: 'ssh:target-b@@pty-wrong' - }) - ], - [siblingWorktreeId]: siblingTabs, - [runtimeWorktreeId]: runtimeTabs - }, - activeWorktreeIdsOnShutdown: [targetWorktreeId], - terminalLayoutsByTabId: {} - } - - store.getState().hydrateWorkspaceSession(session, { - directSshAuthority: authority, - replaceWorkspaceKeys: [targetWorktreeId] - }) - - expect(store.getState().tabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) - expect(store.getState().tabsByWorktree[localWorktreeId]).toBe(localTabs) - expect(store.getState().tabsByWorktree[runtimeWorktreeId]).toBe(runtimeTabs) - expect(store.getState().tabsByWorktree[folderWorktreeId]).toBe(folderTabs) - expect(store.getState().ptyIdsByTabId[siblingTab.id]).toEqual([siblingTab.ptyId]) - expect(store.getState().ptyIdsByTabId[runtimeTab.id]).toEqual([runtimeTab.ptyId]) - expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toBe(runtimeOwners) - expect(store.getState().activeWorktreeId).toBe(siblingWorktreeId) - expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ - [targetTab.id]: 'ssh:target-a@@pty-a' - }) - - await store.getState().reconnectPersistedTerminals(undefined, { - directSshAuthority: authority, - workspaceKeys: [targetWorktreeId] - }) - - const targetTabs = store.getState().tabsByWorktree[targetWorktreeId] - expect(targetTabs.find((tab) => tab.id === targetTab.id)?.ptyId).toBe('ssh:target-a@@pty-a') - expect(targetTabs.find((tab) => tab.id === 'tab-wrong-host')?.ptyId).toBeNull() - expect(store.getState().tabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) - expect(store.getState().tabsByWorktree[runtimeWorktreeId]).toBe(runtimeTabs) - expect(store.getState().workspaceSessionReady).toBe(true) - }) }) describe('hydrationSucceeded flag (issue #1158)', () => { diff --git a/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts b/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts index 88bebff58f2..ca4cb4584b3 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts @@ -1,18 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import type { - HostQualifiedDetectedWorktreeResult, - ListDetectedWorktreesArgs -} from '../../../../shared/detected-worktree-provider-contract' -import type { SshProviderEpoch } from '../../../../shared/ssh-types' import { clearHugeRepoWarningDismissalsForTests } from '@/lib/source-control-huge-repo-warning-dismissals' -import { acquireDirectSshDetectedWorktreeRefresh } from './worktrees' -import { - TEST_SSH_AUTHORITY, - makeDetectedResult, - qualifyDetectedResult -} from './worktrees-detected-listing-fixtures' +import { TEST_SSH_AUTHORITY, makeDetectedResult } from './worktrees-detected-listing-fixtures' import { makeWorktree } from './worktrees-slice-test-fixtures' import { createTestStore, @@ -513,219 +503,4 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo).toBe(worktreesByRepo) expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo) }) - - it('keeps worktree maps byte-identical for stale and malformed direct results', async () => { - const store = createTestStore() - const existing = makeWorktree({ - id: 'repo-ssh::/home/orca/existing', - repoId: 'repo-ssh', - path: '/home/orca/existing', - hostId: 'ssh:ssh-1' - }) - store.setState({ - repos: [ - { - id: 'repo-ssh', - path: '/home/orca/repo', - displayName: 'SSH Repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - ], - worktreesByRepo: { 'repo-ssh': [existing] }, - detectedWorktreesByRepo: { - 'repo-ssh': makeDetectedResult('repo-ssh', [existing]) - } - } as Partial) - const beforeWorktrees = store.getState().worktreesByRepo - const beforeDetected = store.getState().detectedWorktreesByRepo - const beforeBytes = JSON.stringify([beforeWorktrees, beforeDetected]) - let request!: ListDetectedWorktreesArgs - let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void - mockApi.worktrees.listDetected.mockImplementationOnce( - async (args: ListDetectedWorktreesArgs) => { - request = args - return new Promise((resolve) => { - resolveProvider = resolve - }) - } - ) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1', - authority: TEST_SSH_AUTHORITY - }) - const nextAuthority = { - ...TEST_SSH_AUTHORITY, - providerEpoch: 'provider-ssh-2' as SshProviderEpoch, - connectionGeneration: 2 - } - store.setState({ - sshConnectionStates: new Map([ - [ - 'ssh-1', - { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: nextAuthority.providerEpoch, - connectionGeneration: nextAuthority.connectionGeneration - } - ] - ]) - } as Partial) - const subscriber = vi.fn() - const unsubscribe = store.subscribe(subscriber) - resolveProvider( - qualifyDetectedResult( - request, - makeDetectedResult('repo-ssh', [ - makeWorktree({ - id: 'repo-ssh::/home/orca/stale', - repoId: 'repo-ssh', - path: '/home/orca/stale' - }) - ]) - ) - ) - const staleResult = await lease.result - - expect(lease.merge(staleResult)).toMatchObject({ status: 'stale' }) - expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) - expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) - expect(JSON.stringify([beforeWorktrees, beforeDetected])).toBe(beforeBytes) - expect(subscriber).not.toHaveBeenCalled() - unsubscribe() - - store.setState({ - sshConnectionStates: new Map([ - [ - 'ssh-1', - { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: nextAuthority.providerEpoch, - connectionGeneration: nextAuthority.connectionGeneration - } - ] - ]) - } as Partial) - mockApi.worktrees.listDetected.mockImplementationOnce( - async (args: ListDetectedWorktreesArgs) => ({ - ...qualifyDetectedResult(args, makeDetectedResult(args.repoId, [])), - repoId: 'wrong-repo' - }) - ) - const malformed = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1', - authority: nextAuthority - }) - const malformedResult = await malformed.result - - expect(malformed.merge(malformedResult)).toMatchObject({ - status: 'rejected' - }) - expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) - expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) - }) - - it('keeps duplicate repo IDs isolated across direct SSH hosts', async () => { - const store = createTestStore() - const authorityA = { - targetId: 'ssh-a', - providerEpoch: 'provider-a' as SshProviderEpoch, - connectionGeneration: 1 - } - const authorityB = { - targetId: 'ssh-b', - providerEpoch: 'provider-b' as SshProviderEpoch, - connectionGeneration: 4 - } - const worktreeA = makeWorktree({ - id: 'same-repo::/srv/a', - repoId: 'same-repo', - path: '/srv/a' - }) - const worktreeB = makeWorktree({ - id: 'same-repo::/srv/b', - repoId: 'same-repo', - path: '/srv/b' - }) - store.setState({ - repos: [ - { - id: 'same-repo', - path: '/srv/repo-a', - displayName: 'Repo A', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-a' - }, - { - id: 'same-repo', - path: '/srv/repo-b', - displayName: 'Repo B', - badgeColor: '#111', - addedAt: 1, - connectionId: 'ssh-b' - } - ], - sshConnectionStates: new Map([ - [ - 'ssh-a', - { - targetId: 'ssh-a', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: authorityA.providerEpoch, - connectionGeneration: authorityA.connectionGeneration - } - ], - [ - 'ssh-b', - { - targetId: 'ssh-b', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: authorityB.providerEpoch, - connectionGeneration: authorityB.connectionGeneration - } - ] - ]) - } as Partial) - mockApi.worktrees.listDetected - .mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => - qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktreeA])) - ) - .mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => - qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktreeB])) - ) - - const refreshA = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'same-repo', - executionHostId: 'ssh:ssh-a', - authority: authorityA - }) - const refreshB = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'same-repo', - executionHostId: 'ssh:ssh-b', - authority: authorityB - }) - refreshA.merge(await refreshA.result) - refreshB.merge(await refreshB.result) - - expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(2) - expect(refreshA.providerRequestId).not.toBe(refreshB.providerRequestId) - expect(store.getState().worktreesByRepo['same-repo']).toEqual([ - { ...worktreeA, hostId: 'ssh:ssh-a' }, - { ...worktreeB, hostId: 'ssh:ssh-b' } - ]) - }) }) diff --git a/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts index 0d77e8e4667..2c294dc9d4c 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' -import type { - HostQualifiedDetectedWorktreeResult, - ListDetectedWorktreesArgs -} from '../../../../shared/detected-worktree-provider-contract' +import type { ListDetectedWorktreesArgs } from '../../../../shared/detected-worktree-provider-contract' import { clearHugeRepoWarningDismissalsForTests } from '@/lib/source-control-huge-repo-warning-dismissals' -import { acquireDirectSshDetectedWorktreeRefresh } from './worktrees' import { TEST_SSH_AUTHORITY, makeDetectedResult, @@ -466,164 +462,6 @@ describe('fetchWorktrees', () => { expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) }) - it('exposes shared direct leases and reports cancellation only for the last waiter', async () => { - const store = createTestStore() - let request!: ListDetectedWorktreesArgs - let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void - const provider = new Promise((resolve) => { - resolveProvider = resolve - }) - mockApi.worktrees.listDetected.mockImplementationOnce( - async (args: ListDetectedWorktreesArgs) => { - request = args - return provider - } - ) - - const input = { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1' as const, - authority: TEST_SSH_AUTHORITY - } - const first = acquireDirectSshDetectedWorktreeRefresh(store, input) - const second = acquireDirectSshDetectedWorktreeRefresh(store, input) - - expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) - expect(first.providerRequestId).toBe(second.providerRequestId) - expect(first.waiterLeaseId).not.toBe(second.waiterLeaseId) - expect(first.release('superseded')).toBe('retained') - expect(mockApi.worktrees.cancelListDetected).not.toHaveBeenCalled() - expect(second.release('invalidated')).toBe('cancel-started') - expect(second.release('stopped')).toBe('already-settled') - expect(mockApi.worktrees.cancelListDetected).toHaveBeenCalledWith({ - providerRequestId: first.providerRequestId - }) - - resolveProvider(qualifyDetectedResult(request, makeDetectedResult('repo-ssh', []))) - await provider - await Promise.resolve() - }) - - it('merges one exact direct provider result once without a second scan', async () => { - const store = createTestStore() - const worktree = makeWorktree({ - id: 'repo-ssh::/home/orca/feature', - repoId: 'repo-ssh', - path: '/home/orca/feature' - }) - store.setState({ - repos: [ - { - id: 'repo-ssh', - path: '/home/orca/repo', - displayName: 'SSH Repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - ] - } as Partial) - mockApi.worktrees.listDetected.mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => - qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktree])) - ) - const subscriber = vi.fn() - const unsubscribe = store.subscribe(subscriber) - - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1', - authority: TEST_SSH_AUTHORITY - }) - const providerResult = await lease.result - const firstMerge = lease.merge(providerResult) - const secondMerge = lease.merge(providerResult) - - expect(firstMerge).toBe(providerResult) - expect(secondMerge).toBe(providerResult) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) - expect(subscriber).toHaveBeenCalledTimes(1) - expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { ...worktree, hostId: 'ssh:ssh-1' } - ]) - unsubscribe() - }) - - it('rejects a late duplicate exact-host owner with zero mutation publications', async () => { - const store = createTestStore() - const existing = makeWorktree({ - id: 'repo-ssh::/home/orca/existing', - repoId: 'repo-ssh', - path: '/home/orca/existing', - branch: 'refs/heads/old', - hostId: 'ssh:ssh-1' - }) - store.setState({ - repos: [ - { - id: 'repo-ssh', - path: '/home/orca/repo', - displayName: 'SSH Repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - ], - worktreesByRepo: { 'repo-ssh': [existing] }, - detectedWorktreesByRepo: { - 'repo-ssh': makeDetectedResult('repo-ssh', [existing]) - } - } as Partial) - let request!: ListDetectedWorktreesArgs - let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void - mockApi.worktrees.listDetected.mockImplementationOnce( - (args: ListDetectedWorktreesArgs) => - new Promise((resolve) => { - request = args - resolveProvider = resolve - }) - ) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1', - authority: TEST_SSH_AUTHORITY - }) - - store.setState((state) => ({ - repos: [ - ...state.repos, - { - id: 'repo-ssh', - path: '/home/orca/duplicate', - displayName: 'Duplicate SSH Repo', - badgeColor: '#111', - addedAt: 1, - connectionId: 'ssh-1' - } - ] - })) - const beforeWorktrees = store.getState().worktreesByRepo - const beforeDetected = store.getState().detectedWorktreesByRepo - const subscriber = vi.fn() - const unsubscribe = store.subscribe(subscriber) - resolveProvider( - qualifyDetectedResult( - request, - makeDetectedResult('repo-ssh', [ - { - ...existing, - branch: 'refs/heads/new' - } - ]) - ) - ) - - expect(lease.merge(await lease.result)).toMatchObject({ status: 'stale' }) - expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) - expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) - expect(subscriber).not.toHaveBeenCalled() - unsubscribe() - }) - it('fails closed before provider acquisition when direct authority is partial', async () => { const store = createTestStore() const worktreesByRepo = store.getState().worktreesByRepo diff --git a/src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts b/src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts index 1a6d8161912..a585def1496 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import type { WorktreeCatalogVersion } from '../../../../shared/worktree/catalog-version' -import { acquireDirectSshDetectedWorktreeRefresh } from './worktrees' import { TEST_SSH_AUTHORITY, makeDetectedResult, @@ -459,8 +458,7 @@ describe('the startup hydration purge behind a listing that is no longer current }) }) -// Why this suite exists: the SSH reconnect preparation ends on any repo reporting 'stale' and -// skips its post-connect workspace sync, which nothing retries while the connection holds. +// An older listing must neither roll back the create's catalog nor report the repo stale. describe('a direct SSH listing older than an applied create', () => { const sshHost = 'ssh:ssh-1' @@ -502,20 +500,6 @@ describe('a direct SSH listing older than an applied create', () => { return created } - it('is not applied, and reports the repo current rather than stale', async () => { - const store = createTestStore() - const created = seedSsh(store) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: sshHost, - authority: TEST_SSH_AUTHORITY - }) - const providerResult = await lease.result - - expect(lease.merge(providerResult)).toBe(providerResult) - expect(store.getState().worktreesByRepo['repo-ssh']?.map((w) => w.id)).toEqual([created.id]) - }) - it('a direct-authority fetchWorktrees caller gets the same report, without a relist', async () => { const store = createTestStore() const created = seedSsh(store) @@ -530,46 +514,4 @@ describe('a direct SSH listing older than an applied create', () => { expect(mockApi.worktrees.listDetected).toHaveBeenCalledOnce() expect(store.getState().worktreesByRepo['repo-ssh']?.map((w) => w.id)).toEqual([created.id]) }) - - it('still reports stale when the repo owner went away during the listing, though it is also older', async () => { - const store = createTestStore() - seedSsh(store) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: sshHost, - authority: TEST_SSH_AUTHORITY - }) - const providerResult = await lease.result - store.setState({ repos: [] }) - - expect(lease.merge(providerResult)).toMatchObject({ status: 'stale' }) - }) - - it('control: still reports stale when the connection moved during the listing', async () => { - const store = createTestStore() - seedSsh(store) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: sshHost, - authority: TEST_SSH_AUTHORITY - }) - const providerResult = await lease.result - store.setState({ - sshConnectionStates: new Map([ - [ - TEST_SSH_AUTHORITY.targetId, - { - targetId: TEST_SSH_AUTHORITY.targetId, - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: TEST_SSH_AUTHORITY.providerEpoch, - connectionGeneration: TEST_SSH_AUTHORITY.connectionGeneration + 1 - } - ] - ]) - }) - - expect(lease.merge(providerResult)).toMatchObject({ status: 'stale' }) - }) }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 873bb72253a..e7c5d5a40a0 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,8 +71,6 @@ export { setDetachedHeadAutoDerivedDisplayNameForTests } from './worktrees/metadata/detached-head-display-name' export { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees/listing/authoritative-worktree-removal-memory' -export type { DirectSshDetectedWorktreeRefresh } from './worktrees/listing/known-ssh-worktree-fetch' -export { acquireDirectSshDetectedWorktreeRefresh } from './worktrees/listing/known-ssh-worktree-fetch' export const createWorktreeSlice: StateCreator = (set, get) => ({ ...worktreeSliceInitialState, diff --git a/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts b/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts index 68b478e9f26..3ec7e7a8f3b 100644 --- a/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts +++ b/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts @@ -1,15 +1,11 @@ -import type { StateCreator, StoreApi } from 'zustand' +import type { StateCreator } from 'zustand' import type { AppState } from '../../../types' import type { WorktreeSlice } from '../../worktree-helpers' import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' import type { - HostQualifiedDetectedWorktreeResult, HostQualifiedKnownWorktreeResult, - ProviderRequestId, SshExecutionHostId } from '../../../../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority } from '../../../../../../shared/ssh-types' -import type { DetectedWorktreeRefreshLease } from '../../detected-worktree-refresh-leases' import { areDetectedWorktreeResultsEqual, areWorktreesEqual } from './worktree-catalog-visibility' import { getProjectHostSetupForRepoHost, @@ -20,20 +16,9 @@ import { worktreeMatchesHost } from './worktree-host-ownership' import { getCurrentDirectSshAuthority } from './direct-ssh-authority' -import { - acquireDetectedWorktreeRefreshLeaseForRepo, - normalizeNotAdmittedProviderResult, - qualifiedProviderResultIsAdmitted -} from './detected-worktree-refresh' import { isDetectedWorktreeListResult } from './detected-worktree-provider-request' -import { staleDetectedWorktreeProviderResult } from './detected-worktree-refresh-admission' -import { mergeFetchedWorktrees } from './fetched-worktree-merge' import { getAuthoritativelyRemovedWorktreeIds } from './authoritative-worktree-removal-memory' -import type { - AdmittedDetectedWorktreeRefresh, - DetectedWorktreeRefreshOptions, - WorktreeHostMatchOptions -} from './worktree-slice-types' +import type { WorktreeHostMatchOptions } from './worktree-slice-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' export function appendMissingWorktreesForHost< @@ -181,100 +166,3 @@ export async function runKnownSshWorktreeFetch( }) return admitted ? known : null } - -export type DirectSshDetectedWorktreeRefresh = { - waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] - providerRequestId: ProviderRequestId - result: Promise - release: DetectedWorktreeRefreshLease['release'] - merge(result: HostQualifiedDetectedWorktreeResult): HostQualifiedDetectedWorktreeResult -} - -export function acquireDirectSshDetectedWorktreeRefresh( - store: Pick, 'getState' | 'setState'>, - request: { - repoId: string - executionHostId: SshExecutionHostId - authority: DirectSshAuthority - requireAuthoritative?: boolean - } -): DirectSshDetectedWorktreeRefresh { - const requestStartedState = store.getState() - const requestStartedWorktrees = requestStartedState.worktreesByRepo[request.repoId] - const ownerWasMissingAtStart = !requestStartedState.repos.some( - (repo) => repo.id === request.repoId - ) - const setup = getProjectHostSetupForRepoHost( - requestStartedState, - request.repoId, - request.executionHostId - ) - const options: DetectedWorktreeRefreshOptions = { - executionHostId: request.executionHostId, - directSshAuthority: request.authority, - requireAuthoritative: request.requireAuthoritative - } - const lease = acquireDetectedWorktreeRefreshLeaseForRepo(request.repoId, options) - let mergedResult: HostQualifiedDetectedWorktreeResult | undefined - - return { - waiterLeaseId: lease.waiterLeaseId, - providerRequestId: lease.providerRequestId, - result: lease.result, - release: lease.release, - merge: (providerResult) => { - if (mergedResult) { - return mergedResult - } - if ( - !qualifiedProviderResultIsAdmitted( - providerResult, - lease.providerRequestId, - request.repoId, - options - ) - ) { - mergedResult = normalizeNotAdmittedProviderResult( - providerResult, - lease.providerRequestId, - request.executionHostId - ) - return mergedResult - } - if (request.requireAuthoritative && providerResult.status !== 'complete') { - mergedResult = providerResult - return mergedResult - } - const refresh: AdmittedDetectedWorktreeRefresh = { - status: 'admitted', - result: providerResult.result, - providerResult, - executionHostId: request.executionHostId, - directSshAuthority: request.authority - } - const outcome = mergeFetchedWorktrees( - store.setState as Parameters>[0], - { - repoId: request.repoId, - hostId: request.executionHostId, - ownerWasMissingAtStart, - requestStartedWorktrees, - setup, - refresh - } - ) - // Why 'superseded' is current: a newer catalog is already applied for this host. 'stale' would - // end a reconnect preparation, and nothing retries it while the connection holds. - switch (outcome) { - case 'applied': - case 'superseded': - mergedResult = providerResult - break - case 'not-current': - mergedResult = staleDetectedWorktreeProviderResult(refresh) ?? providerResult - break - } - return mergedResult - } - } -} diff --git a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts index 8a694e9b515..6a5ea2afda9 100644 --- a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts +++ b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts @@ -1,17 +1,11 @@ import { clearDirectSshTerminalBindings } from '../slices/direct-ssh-terminal-recovery' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -import { - isCurrentDirectSshAuthority, - resolveDirectSshTerminalKeys -} from './terminal-pty-identities' +import { resolveDirectSshTerminalKeys } from './terminal-pty-identities' export function createDirectSshTerminalBindingActions( set: TerminalStoreSet, _get: TerminalStoreGet -): Pick< - TerminalSlice, - 'clearDirectSshTargetPtyBindings' | 'invalidateStaleDirectSshTargetPtyBindings' -> { +): Pick { return { clearDirectSshTargetPtyBindings: (targetId) => { let clearedCount = 0 @@ -21,22 +15,6 @@ export function createDirectSshTerminalBindingActions( return result.patch ?? s }) return clearedCount - }, - // Why: no pane binds an `ssh:` PTY any more, so every binding a new authority finds is stale. - invalidateStaleDirectSshTargetPtyBindings: (authority) => { - let clearedCount = 0 - set((s) => { - if (!isCurrentDirectSshAuthority(s, authority)) { - return s - } - const result = clearDirectSshTerminalBindings( - s, - resolveDirectSshTerminalKeys(s, authority.targetId) - ) - clearedCount = result.clearedCount - return result.patch ?? s - }) - return clearedCount } } } diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 56c7dfd82e5..c7b32b08237 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -9,7 +9,6 @@ import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' @@ -27,8 +26,7 @@ import type { import type { AutomaticAgentResumeClaim, CodexRestartNotice, - HydrateWorkspaceSessionOptions, - ReconnectPersistedTerminalsOptions + HydrateWorkspaceSessionOptions } from './terminal-contracts' export type TerminalActions = { @@ -145,7 +143,6 @@ export type TerminalActions = { /** Records the relay's own answer that a PTY id is gone; the one `exited` a respawn may act on. */ markPtySourceDisowned: (ptyId: string) => void clearDirectSshTargetPtyBindings: (targetId: string) => number - invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number /** Awaited verified shutdown; unlike closeTab, physical teardown gates the state commit. */ shutdownWorktreeTerminals: ( worktreeId: string, @@ -279,8 +276,5 @@ export type TerminalActions = { options?: HydrateWorkspaceSessionOptions ) => void /** Publishes wake hints without spawning; global completion alone opens the readiness gate. */ - reconnectPersistedTerminals: ( - signal?: AbortSignal, - options?: ReconnectPersistedTerminalsOptions - ) => Promise + reconnectPersistedTerminals: (signal?: AbortSignal) => Promise } diff --git a/src/renderer/src/store/terminals/terminal-contracts.ts b/src/renderer/src/store/terminals/terminal-contracts.ts index 7bb43481ad5..e67343c4b86 100644 --- a/src/renderer/src/store/terminals/terminal-contracts.ts +++ b/src/renderer/src/store/terminals/terminal-contracts.ts @@ -1,6 +1,5 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' import type { HostSessionSlices } from '@/lib/workspace-session-host-split' @@ -24,18 +23,10 @@ export type CodexRestartNotice = { dismissed?: true } -/** Scoped direct-SSH hydration replaces only named workspace keys and authority. */ export type HydrateWorkspaceSessionOptions = { - directSshAuthority?: DirectSshAuthority runtimeHostIdByWorkspaceSessionKey?: Record /** Rows parked for hosts that lost a contested workspace id; omitted leaves the store's copy. */ contestedHostWorkspaceSessions?: HostSessionSlices /** Partition each restored session key was read from; omitted leaves the store's copy. */ contestedPrimaryHostBySessionKey?: Record } & WorkspaceSessionHydrationOptions - -/** Scoped reconnect must still match this exact provider epoch and connection generation. */ -export type ReconnectPersistedTerminalsOptions = { - directSshAuthority: DirectSshAuthority - workspaceKeys: readonly string[] -} diff --git a/src/renderer/src/store/terminals/terminal-pty-identities.ts b/src/renderer/src/store/terminals/terminal-pty-identities.ts index 08cd8dc9865..a227560e12d 100644 --- a/src/renderer/src/store/terminals/terminal-pty-identities.ts +++ b/src/renderer/src/store/terminals/terminal-pty-identities.ts @@ -1,21 +1,8 @@ import type { AppState } from '../types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { resolveDirectSshTerminalWorkspaceKeys } from '../slices/direct-ssh-terminal-workspace-scope' -export function isCurrentDirectSshAuthority( - state: AppState, - authority: DirectSshAuthority -): boolean { - const current = state.sshConnectionStates.get(authority.targetId) - return Boolean( - current?.status === 'connected' && - current.providerEpoch === authority.providerEpoch && - current.connectionGeneration === authority.connectionGeneration - ) -} - export function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set { return resolveDirectSshTerminalWorkspaceKeys( { diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index d2996c7477d..06eb392f30c 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -1,6 +1,5 @@ import type { AppState } from '../types' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { resolveAgentPaneAuthorityKey } from '../slices/agent-pane-authority' import type { HydrateWorkspaceSessionOptions } from './terminal-contracts' @@ -127,24 +126,6 @@ export function targetScopedWorkspaceHydrationPatch( {}, targetTabIds ) - const authority = options.directSshAuthority - if (authority) { - for (const workspaceKey of hydrated.pendingReconnectWorktreeIds) { - if (!workspaceKeys.has(workspaceKey)) { - continue - } - for (const tab of session.tabsByWorktree[workspaceKey] ?? []) { - // Why: rows hydration dropped (invalid id, canonical duplicate) would leak reconnect keys nothing owns. - if (!retainedTargetTabIds.has(tab.id)) { - continue - } - const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId - if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) { - pendingReconnectPtyIdByTabId[tab.id] = ptyId - } - } - } - } const activeOutsideScope = state.activeWorktreeId != null && !workspaceKeys.has(state.activeWorktreeId) const sleepingAgentSessionsByPaneKey = Object.fromEntries([ diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 9cfe4e687e8..e7e2197b54f 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -67,7 +67,7 @@ export function createWorkspaceTerminalHydrationActions( sleepingAgentSessionsByPaneKey, tabsByWorktree, validTabIds - } = buildWorkspaceTerminalRowPlan(session, validWorktreeIds, options) + } = buildWorkspaceTerminalRowPlan(session, validWorktreeIds) const fallbackActiveWorktreeId = !session.activeWorktreeId && session.activeRepoId && diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts index c379a85774c..7c4a916ab41 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -1,20 +1,15 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -import { isCurrentDirectSshAuthority } from './terminal-pty-identities' export function createWorkspaceTerminalReconnectActions( set: TerminalStoreSet, get: TerminalStoreGet ): Pick { return { - reconnectPersistedTerminals: async (signal, options) => { - if ( - signal?.aborted || - (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) - ) { + reconnectPersistedTerminals: async (signal) => { + if (signal?.aborted) { return } const { @@ -25,14 +20,8 @@ export function createWorkspaceTerminalReconnectActions( tabsByWorktree, ptyIdsByTabId } = get() - const scopedWorkspaceKeys = options ? new Set(options.workspaceKeys) : null - const ids = (pendingReconnectWorktreeIds ?? []).filter( - (id) => !scopedWorkspaceKeys || scopedWorkspaceKeys.has(id) - ) + const ids = pendingReconnectWorktreeIds ?? [] if (ids.length === 0) { - if (options) { - return - } set({ workspaceSessionReady: true, pendingReconnectWorktreeIds: [], @@ -65,13 +54,7 @@ export function createWorkspaceTerminalReconnectActions( const tabId = tab.id const layout = terminalLayoutsByTabId[tabId] const leafPtyMap = layout?.ptyIdsByLeafId ?? {} - const pendingPtyId = pendingReconnectPtyIdByTabId[tabId] - const tabLevelPtyId = - options && - parseAppSshPtyId(pendingPtyId ?? '')?.connectionId !== - options.directSshAuthority.targetId - ? undefined - : pendingPtyId + const tabLevelPtyId = pendingReconnectPtyIdByTabId[tabId] const hasLeafMappings = Object.keys(leafPtyMap).length > 0 // Why: publish live PTY hints before mount (pty-connection reattaches later) so the // sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. @@ -99,24 +82,13 @@ export function createWorkspaceTerminalReconnectActions( } } // Why: keep deferred SSH session IDs for post-cleanup reconnect. - const scopedTabIds = new Set( - [...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) => - (tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ) - ) - const deferredSshSessionIdsByTabId: Record = options - ? Object.fromEntries( - Object.entries(get().deferredSshSessionIdsByTabId).filter( - ([tabId]) => !scopedTabIds.has(tabId) - ) - ) - : {} + const deferredSshSessionIdsByTabId: Record = {} for (const worktreeId of ids) { const worktree = worktreeById.get(worktreeId) // Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map. const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) const repo = repoId ? (repoById.get(repoId) ?? null) : null - const connectionId = options?.directSshAuthority.targetId ?? repo?.connectionId + const connectionId = repo?.connectionId if (!connectionId) { continue } @@ -136,44 +108,21 @@ export function createWorkspaceTerminalReconnectActions( const tabs = tabsByWorktree[worktreeId] ?? [] for (const tab of tabs) { const sessionId = pendingReconnectPtyIdByTabId[tab.id] - if ( - sessionId && - (!options || parseAppSshPtyId(sessionId)?.connectionId === connectionId) - ) { + if (sessionId) { deferredSshSessionIdsByTabId[tab.id] = sessionId } } } - if ( - signal?.aborted || - (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) - ) { + if (signal?.aborted) { return } - const remainingReconnectWorktreeIds = options - ? pendingReconnectWorktreeIds.filter((id) => !scopedWorkspaceKeys?.has(id)) - : [] - const remainingReconnectTabByWorktree = options - ? Object.fromEntries( - Object.entries(pendingReconnectTabByWorktree).filter( - ([workspaceKey]) => !scopedWorkspaceKeys?.has(workspaceKey) - ) - ) - : {} - const remainingReconnectPtyIdByTabId = options - ? Object.fromEntries( - Object.entries(pendingReconnectPtyIdByTabId).filter( - ([tabId]) => !scopedTabIds.has(tabId) - ) - ) - : {} set({ ...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}), ...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}), - ...(options ? {} : { workspaceSessionReady: true }), - pendingReconnectWorktreeIds: remainingReconnectWorktreeIds, - pendingReconnectTabByWorktree: remainingReconnectTabByWorktree, - pendingReconnectPtyIdByTabId: remainingReconnectPtyIdByTabId, + workspaceSessionReady: true, + pendingReconnectWorktreeIds: [], + pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {}, deferredSshSessionIdsByTabId }) } diff --git a/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts index fc565f3a3b7..3890b7d229d 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts @@ -7,7 +7,6 @@ import { hydrateWorkspaceTerminalRows } from '../slices/terminal-session-row-hydration' import { removeSleepingAgentSessionsForTab } from '../slices/terminal-tab-retirement' -import type { HydrateWorkspaceSessionOptions } from './terminal-contracts' export type WorkspaceTerminalRowPlan = { canonicalTabIdBySubsumedTabId: Map @@ -20,23 +19,13 @@ export type WorkspaceTerminalRowPlan = { export function buildWorkspaceTerminalRowPlan( session: WorkspaceSessionState, - validWorktreeIds: ReadonlySet, - options?: HydrateWorkspaceSessionOptions + validWorktreeIds: ReadonlySet ): WorkspaceTerminalRowPlan { - // Remote wire rows are authoritative independently of locally persisted unified tabs. - const remoteSnapshotWorkspaceKeys = new Set( - options?.directSshAuthority ? (options.replaceWorkspaceKeys ?? []) : [] - ) const rowHydrationByWorktree = Object.entries(session.tabsByWorktree) .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) .map( ([worktreeId, tabs]) => - [ - worktreeId, - hydrateWorkspaceTerminalRows(session, worktreeId, tabs, { - rowsFromRemoteSnapshot: remoteSnapshotWorkspaceKeys.has(worktreeId) - }) - ] as const + [worktreeId, hydrateWorkspaceTerminalRows(session, worktreeId, tabs)] as const ) const tabsByWorktree: Record = Object.fromEntries( rowHydrationByWorktree diff --git a/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts b/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts deleted file mode 100644 index 10d71e95a24..00000000000 --- a/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { eventSchemas, type EventProps } from './telemetry-events' - -const validEvent = { - mode: 'reconnect', - reason: 'wake_refresh', - outcome: 'degraded', - terminal_retried_count: 3, - terminal_stale_binding_cleared_count: 1, - terminal_correction_succeeded_count: 2, - catalog_complete_count: 1, - catalog_degraded_count: 0, - catalog_stale_count: 0, - repo_complete_count: 2, - repo_non_authoritative_count: 1, - repo_retrying_count: 1, - repo_timed_out_count: 1, - repo_cancel_budget_exhausted_count: 0, - repo_canceled_count: 0, - repo_stale_count: 0, - repo_rejected_count: 1, - lineage_complete_count: 0, - lineage_degraded_count: 1, - lineage_canceled_count: 0, - lineage_stale_count: 0, - lineage_not_started_count: 0, - git_worktree_count: 4, - folder_workspace_count: 2, - ambiguous_owner_count: 1, - contradictory_owner_count: 1, - total_duration_ms: 750, - terminal_finalization_duration_ms: 5, - catalog_duration_ms: 25, - queue_wait_sample_count: 3, - queue_wait_duration_ms_p50: 10, - queue_wait_duration_ms_p95: 30, - queue_wait_duration_ms_p99: 30, - queue_wait_duration_ms_max: 30, - provider_execution_sample_count: 3, - provider_execution_duration_ms_p50: 100, - provider_execution_duration_ms_p95: 300, - provider_execution_duration_ms_p99: 300, - provider_execution_duration_ms_max: 300, - timeout_retry_count: 1, - locally_settled_waiter_count: 3, - cancel_debt_count: 1, - replacement_admission_delayed_count: 0, - overlapping_join_count: 1, - coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: 3, - estimated_late_work_allowance_count: 1, - authority_rotation_count: 1, - damped_preparation_count: 0 -} satisfies EventProps<'direct_ssh_reconnect_operation'> - -describe('direct SSH reconnect telemetry schema', () => { - it('accepts the aggregate count and duration distribution', () => { - expect(eventSchemas.direct_ssh_reconnect_operation.safeParse(validEvent).success).toBe(true) - }) - - it.each([ - 'target_id', - 'repo_id', - 'host', - 'path', - 'label', - 'username', - 'request_id', - 'lease_id', - 'terminal_id', - 'error', - 'raw_error' - ])('rejects identifier or raw failure field %s', (field) => { - expect( - eventSchemas.direct_ssh_reconnect_operation.safeParse({ - ...validEvent, - [field]: 'sensitive-value' - }).success - ).toBe(false) - }) - - it('keeps timeout, rejection, cancellation, and stale counts independent', () => { - const parsed = eventSchemas.direct_ssh_reconnect_operation.parse({ - ...validEvent, - repo_timed_out_count: 2, - repo_rejected_count: 3, - repo_canceled_count: 4, - repo_stale_count: 5 - }) - - expect([ - parsed.repo_timed_out_count, - parsed.repo_rejected_count, - parsed.repo_canceled_count, - parsed.repo_stale_count - ]).toEqual([2, 3, 4, 5]) - }) - - it('rejects concurrency and late-work values beyond scheduler policy', () => { - expect( - eventSchemas.direct_ssh_reconnect_operation.safeParse({ - ...validEvent, - coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: 6 - }).success - ).toBe(false) - expect( - eventSchemas.direct_ssh_reconnect_operation.safeParse({ - ...validEvent, - estimated_late_work_allowance_count: 3 - }).success - ).toBe(false) - }) -}) diff --git a/src/shared/host-lineage-contract.ts b/src/shared/host-lineage-contract.ts deleted file mode 100644 index 7bb69f02bb9..00000000000 --- a/src/shared/host-lineage-contract.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' -import type { SshExecutionHostId } from './detected-worktree-provider-contract' -import type { DirectSshAuthority } from './ssh-types' -import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' - -export type ListDesktopLineageForHostArgs = - | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | { - executionHostId: SshExecutionHostId - expectedAuthority: DirectSshAuthority - } - -export type HostLineageSnapshot = - | { - authoritative: true - authority: - | { kind: 'local'; executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | ({ - kind: 'direct-ssh' - executionHostId: SshExecutionHostId - } & DirectSshAuthority) - worktreeLineageById: Record - workspaceLineageByChildKey: Record - } - | { - authoritative: false - executionHostId: ExecutionHostId - reason: 'ambiguous-owner' | 'authority-unknown' | 'stale' | 'unavailable' | 'rejected' - } diff --git a/src/shared/host-repo-catalog-contract.ts b/src/shared/host-repo-catalog-contract.ts deleted file mode 100644 index 30f88d7f271..00000000000 --- a/src/shared/host-repo-catalog-contract.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' -import type { SshExecutionHostId } from './detected-worktree-provider-contract' -import type { DirectSshAuthority } from './ssh-types' -import type { Repo } from './repo-types' - -export type ListReposForExecutionHostArgs = - | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | { - executionHostId: SshExecutionHostId - expectedAuthority: DirectSshAuthority - } - -export type HostRepoCatalogSnapshot = - | { - authoritative: true - authority: - | { kind: 'local'; executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | ({ - kind: 'direct-ssh' - executionHostId: SshExecutionHostId - } & DirectSshAuthority) - repos: readonly Repo[] - } - | { - authoritative: false - executionHostId: ExecutionHostId - reason: 'authority-unknown' | 'stale' | 'unavailable' | 'rejected' - } diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 4cfa4ac49b5..63d6bd58929 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -30,7 +30,6 @@ import { import { contextualTourOutcomeSchema, contextualTourShownSchema, - directSshReconnectOperationSchema, editorExternalChangeConflictActionSchema, editorExternalChangeConflictShownSchema, setupGuideClosedSchema, @@ -203,7 +202,6 @@ export const eventSchemas = { editor_external_change_conflict_shown: editorExternalChangeConflictShownSchema, editor_external_change_conflict_action: editorExternalChangeConflictActionSchema, - direct_ssh_reconnect_operation: directSshReconnectOperationSchema, ssh_remote_runtime_resolved: sshRemoteRuntimeResolvedSchema, ssh_host_server_decided: sshHostServerDecidedSchema, ssh_host_server_conversion: sshHostServerConversionSchema, diff --git a/src/shared/telemetry-feature-education-event-schemas.ts b/src/shared/telemetry-feature-education-event-schemas.ts index 18e6546026b..0fc1bc7fc95 100644 --- a/src/shared/telemetry-feature-education-event-schemas.ts +++ b/src/shared/telemetry-feature-education-event-schemas.ts @@ -119,59 +119,3 @@ export const editorExternalChangeConflictActionSchema = z transport: z.enum(['local', 'ssh', 'runtime']) }) .strict() - -export const directSshReconnectCountSchema = z.number().int().min(0).max(1_000_000) -export const directSshReconnectDurationSchema = z.number().int().min(0).max(86_400_000) -export const directSshReconnectOperationSchema = z - .object({ - mode: z.enum(['reconnect', 'prepare_only']), - reason: z.enum(['reconnect', 'initial_hydration', 'workspace_snapshot', 'wake_refresh']), - outcome: z.enum(['complete', 'degraded', 'canceled', 'stale', 'stopped', 'stabilizing']), - terminal_retried_count: directSshReconnectCountSchema, - terminal_stale_binding_cleared_count: directSshReconnectCountSchema, - terminal_correction_succeeded_count: directSshReconnectCountSchema, - catalog_complete_count: directSshReconnectCountSchema, - catalog_degraded_count: directSshReconnectCountSchema, - catalog_stale_count: directSshReconnectCountSchema, - repo_complete_count: directSshReconnectCountSchema, - repo_non_authoritative_count: directSshReconnectCountSchema, - repo_retrying_count: directSshReconnectCountSchema, - repo_timed_out_count: directSshReconnectCountSchema, - repo_cancel_budget_exhausted_count: directSshReconnectCountSchema, - repo_canceled_count: directSshReconnectCountSchema, - repo_stale_count: directSshReconnectCountSchema, - repo_rejected_count: directSshReconnectCountSchema, - lineage_complete_count: directSshReconnectCountSchema, - lineage_degraded_count: directSshReconnectCountSchema, - lineage_canceled_count: directSshReconnectCountSchema, - lineage_stale_count: directSshReconnectCountSchema, - lineage_not_started_count: directSshReconnectCountSchema, - git_worktree_count: directSshReconnectCountSchema, - folder_workspace_count: directSshReconnectCountSchema, - ambiguous_owner_count: directSshReconnectCountSchema, - contradictory_owner_count: directSshReconnectCountSchema, - total_duration_ms: directSshReconnectDurationSchema, - terminal_finalization_duration_ms: directSshReconnectDurationSchema, - catalog_duration_ms: directSshReconnectDurationSchema, - queue_wait_sample_count: directSshReconnectCountSchema, - queue_wait_duration_ms_p50: directSshReconnectDurationSchema, - queue_wait_duration_ms_p95: directSshReconnectDurationSchema, - queue_wait_duration_ms_p99: directSshReconnectDurationSchema, - queue_wait_duration_ms_max: directSshReconnectDurationSchema, - provider_execution_sample_count: directSshReconnectCountSchema, - provider_execution_duration_ms_p50: directSshReconnectDurationSchema, - provider_execution_duration_ms_p95: directSshReconnectDurationSchema, - provider_execution_duration_ms_p99: directSshReconnectDurationSchema, - provider_execution_duration_ms_max: directSshReconnectDurationSchema, - timeout_retry_count: directSshReconnectCountSchema, - locally_settled_waiter_count: directSshReconnectCountSchema, - cancel_debt_count: directSshReconnectCountSchema, - replacement_admission_delayed_count: directSshReconnectCountSchema, - overlapping_join_count: directSshReconnectCountSchema, - coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: - directSshReconnectCountSchema.max(5), - estimated_late_work_allowance_count: directSshReconnectCountSchema.max(2), - authority_rotation_count: directSshReconnectCountSchema, - damped_preparation_count: directSshReconnectCountSchema - }) - .strict()